AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证agent cloud configuration
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
A gitignore.io-style service for
dev environments instead of .gitignore files. You ask for a set of
modules in the URL and get back a single bash script that installs them:
Like gitignore.io's /api/java,android, the path is a comma-separated list of
modules. The service renders a script by concatenating a fixed preamble with
each requested module and streaming the result.
This repo is the standalone home of the service (extracted from
yschimke/skills). The dynamic renderer
is live at env.coo.ee (see
api/), rendered on demand from the modules/ fragments:查看完整 README ↗coo.ee/env — composable environment bootstrapperA gitignore.io-style service for
dev environments instead of .gitignore files. You ask for a set of
modules in the URL and get back a single bash script that installs them:
curl -fsSL https://env.coo.ee/java,android | bash
Like gitignore.io's /api/java,android, the path is a comma-separated list of
modules. The service renders a script by concatenating a fixed preamble with
each requested module and streaming the result.
This repo is the standalone home of the service (extracted from
yschimke/skills). The dynamic renderer
is live at env.coo.ee (see
api/), rendered on demand from the modules/ fragments:
curl -fsSL https://env.coo.ee/java,android | bash
PATH, it re-exports the
saved environment and exits — no network, no Nix. COOEE_FORCE=1 forces a
re-provision.CODEX_ENV_*_VERSION — it
warns you to set that variable rather than install through Nix. See
Cloud built-ins.curl/OS and probes every host the to-be-installed modules need. If any are
blocked it prints exactly which hosts to allow and where (Claude Code,
Codex, Antigravity / Gemini), then stops — no half-installed
environment. Override with COOEE_IGNORE_HOST_CHECK=1.nix-daemon (via systemd when present, otherwise started directly) so
the root-owned store is reachable.java → Temurin JDK
17 + 21, node → Node.js 22).~/.config/coo-ee/env.sh and, when running
inside a harness, to $CLAUDE_ENV_FILE / $GITHUB_ENV.source — see
Auto-activation below.Re-running is safe and cheap. A warm box hits the short-circuit and is a pure
no-op; otherwise the base install is skipped when nix is already present,
and packages go through nix_ensure, which installs only what's missing and
treats an already-present package as success — so a partial/cold box is
repaired.
| Module | Installs | Needs network access to | Cloud built-in selector |
|---|---|---|---|
base | Nix (Determinate, daemonless) | install.determinate.systems, cache.nixos.org, channels.nixos.org, github.com, objects.githubusercontent.com | — |
java | Temurin JDK, JAVA_HOME; bare java uses the JDK the project pins for Gradle (toolchainVersion in gradle/gradle-daemon-jvm.properties), else 21 (17 + 21 with android); java[17,21] to choose. When the request is for Gradle, also installs build-brief (the Gradle output reducer) and writes its usage guide | cache.nixos.org; github.com + its release-asset CDN for build-brief (bb.staticvar.dev as fallback) | base-image JDK |
android | Full SDK via androidenv: platform-tools (adb), cmdline-tools, the requested platform(s) + build-tools, ANDROID_HOME; android[30,36,wear-33] picks the platform API levels (API 37+ ships only minor-versioned, so android[37.0]); implies android-cli (the Android CLI rides along) | cache.nixos.org, dl.google.com, maven.google.com | — |
android-cli (hidden) | Google's Android CLI — the agent-first android command (scaffold projects, manage AVDs, run Journeys). Downloads the prebuilt binary to ~/.local/bin, puts it on PATH, and runs android init to register its agent skill (opt out with COOEE_ANDROID_CLI_INIT=0). Rides along with android (which implies it) or installs via its own /android-cli one-liner; not shown in the picker (it does not pull the Nix SDK back) |
base is always included; it is the implicit preamble for every request. The
language modules above install a Nix toolchain (or adopt the provider's
built-in); skills and tools are different kinds of dependency — see below.
A module isn't necessarily a Nix package — skills is the first other kind
of dependency. It clones one or more skill repos and symlinks every skill
(any directory with a SKILL.md) into each agent's skills dir (see
Agent homes). Which repos is a
request parameter, given in brackets:
# default: the skills repo this service was extracted from
curl -fsSL https://env.coo.ee/skills | bash
# explicit source(s), with an optional @ref (branch/tag/sha)
curl -fsSL -g 'https://env.coo.ee/skills[yschimke/skills]' | bash
curl -fsSL -g 'https://env.coo.ee/skills[yschimke/skills@v1,owner/more-skills],java' | bash
# just one skill from a repo: add a path segment after owner/repo
curl -fsSL -g 'https://env.coo.ee/skills[yschimke/skills/compose-preview]' | bash
Bracketed requests use
-g '...'. The single quotes stop the shell from globbing[/]before curl runs;-g(--globoff) stops curl from reading them as its own URL range/list syntax (without it,[17,21]fails withbad range in URL). Plain requests likejava,androidneed neither.
The bracketed list is the module's request-time input. The renderer validates
it (lowercase module names; [A-Za-z0-9._/@-] params, so a URL can't inject
shell), dedupes and sorts it (numerically, so 9 < 17 < 21), and injects it
into the script as set_params skills '<owner/repo,...>'. The shell fragment
stays a static source of truth for logic while the renderer supplies the
data. Re-running is idempotent: the repo is cached under
~/.cache/coo-ee/skills/ and re-pulled, and the symlinks are refreshed in place.
A repo entry takes an optional skill selector — a path segment after
owner/repo that links just one skill instead of the whole repo:
skills[owner/repo/<skill>] links only the directory named <skill> (matched
by name, wherever it lives in the repo), and @ref still applies
(owner/repo/<skill>@v1). Bare owner/repo links every skill, as before.
dotfiles takes the same coordinate shape and reuses the same clone/cache/
re-pull path, but applies the repo to $HOME instead of linking skills out of it:
curl -fsSL -g 'https://env.coo.ee/dotfiles[yschimke/dotfiles]' | bash
curl -fsSL -g 'https://env.coo.ee/dotfiles[yschimke/dotfiles@main],tools[ripgrep]' | bash
Unlike skills it has no default — bare dotfiles warns with usage rather
than guessing whose config you meant. How a repo gets applied has no standard, so
it tries the strategies most-specific-first:
install.sh / bootstrap / setup, only with
COOEE_DOTFILES_RUN_INSTALL=1;stow is already on the box and the repo looks like a
package tree (top-level directories, no top-level dotfiles) — never installed
just for this;$HOME.The opt-in on step 1 is the one deliberate restriction: the repo comes from a
request parameter, so running its installer is arbitrary code execution triggered
by a URL, while linking files is not. Turn it on for your own dotfiles.
Everything it would overwrite is moved to <name>.cooee.bak first, and an
existing backup is never overwritten — so the original survives however many
times the module runs. Repo metadata (.git, .github, .gitignore,
.gitmodules, .gitattributes) is never linked; override the list with
COOEE_DOTFILES_SKIP.
The same brackets carry versions for the toolchain modules:
curl -fsSL -g 'https://env.coo.ee/java[17,21],android[30,36,wear-33]' | bash
java[17,21] installs each Temurin major (nixpkgs#temurin-bin-<major>, lowest
owning the java/javac symlinks). With no param, java looks for a
project-specified version marker before falling back to a built-in default:
it reads the JDK the project pins for its Gradle build — toolchainVersion in
the version file in the gradle directory, gradle/gradle-daemon-jvm.properties
(the file Gradle's Daemon JVM
criteria
reads to choose the daemon's JDK) — so the env provisions exactly what
./gradlew will run on. With no such marker it defaults to 21 (the current LTS),
or 17 + 21 when android is also being installed (AGP/Gradle pins 17 while app
code targets 21). An explicit java[…] always overrides the detection.
android[30,36,wear-33] installs those platform API levels (with their
build-tools), and android-emulator[36,wear-33] installs the matching
emulator system images — both built into the SDK by androidenv (see
Android: what android installs). ruby[3]
selects the major series (the nixpkgs default Ruby 3) and ruby[3.4.9] pins a
full version down to its ruby_3_4 major.minor attribute. A param-less request
injects nothing, so it renders byte-identically to before parameters existed.
A fragment can declare render-time dependencies with a directive comment —
# coo.ee:implies <name> — and the renderer pulls them in (transitively) before
canonicalizing. android-emulator implies android, so requesting just the
emulator renders as base,android,android-emulator (with android first, so
its adb/ANDROID_HOME are ready before the emulator block). Keeping the
declaration in the fragment means a module's full definition lives in one file.
The android SDK implies android-cli — Google's Android CLI,
the agent-first android command — so any box with the Nix SDK also gets the
CLI on PATH. The implication is one-directional: android-cli is a lightweight,
standalone binary download, so curl -fsSL https://env.coo.ee/android-cli | bash
installs just the CLI (it manages its own SDK on demand) without building the
heavyweight Nix SDK. Request android too when you want both.
android-cli is marked # coo.ee:hidden, so the picker leaves it off the
top-level module list — it rides along with android or installs via its own
one-liner, rather than being chosen on its own. A hidden module still renders
and installs like any other; it's just kept out of the searchable catalog.
compose is a curated target built on the same mechanism: it implies
java and android (the JDK + SDK that Compose @Preview rendering needs —
but not android-emulator, which the renderer doesn't use) and installs the
compose-preview and compose-ui-builder agent skills (yschimke/skills'
default pair), so one word sets up a Compose preview workflow:
curl -fsSL https://env.coo.ee/compose | bash
It also provisions the native libraries Compose Desktop rendering pulls in.
Desktop previews render through Skia (skiko), whose native library declares
load-time dependencies on libGL.so.1, libX11.so.6, libfontconfig.so.1 and
libstdc++.so.6. On the Nix backend the render JVM is a Nix-store Temurin whose
loader searches the Nix store rather than the system /usr/lib, so without help
the forked render worker dies at load with libGL.so.1: cannot open shared object file. compose builds those libs from the Nix cache (a closure
consistent with the JDK's own glibc) and hands them to the render JVM through
the JDK itself: it builds a wrapper JDK whose bin/java sets
LD_LIBRARY_PATH before exec'ing the real launcher, and points JAVA_HOME at
it. Skip it with COOEE_NO_DESKTOP_GL=1, or adjust the set with
COOEE_DESKTOP_GL_PACKAGES.
It does not pin that wrapper on org.gradle.java.home any more, and retires
the pin if an earlier version of this module left one behind. Gradle 9 rejects
the daemon it produces: the daemon reports java.home from where its libjli
lives — the real store JDK — so the context check compares that against the
wrapper path that asked for it, finds them different, and aborts with "The newly
created daemon process has a different context than expected". That is not
"renders fail", it is every daemon build in the session failing. Gradle picks the
daemon JVM itself now.
The wrapper rather than a session-wide LD_LIBRARY_PATH for two reasons. It
survives harnesses that replay a hook's environment as bare KEY=value lines
with no export (a new variable then never reaches the Gradle daemon). And,
more importantly, store libraries must only ever be loaded by a store JVM: a
store libGL drags the store's own glibc into the process, so a JVM linked
against the system glibc — an Ubuntu JDK 21 picked by jvmToolchain(21), say —
dies at dlopen with libc.so.6: version `GLIBC_ABI_DT_X86_64_PLT' not found. A session-wide variable reaches that JVM too; a wrapped JDK does not
(see compose-ai-tools#3690).
A conventional (non-store) JDK is left alone entirely — its loader finds the
system libs itself, and store libs would only break it. LD_LIBRARY_PATH is
used only as a fallback, when the wrapper JDK could not be built.
The wrapper fixes the JVM it launches and nothing it forks, though, and Gradle
forks a different JDK all the time: jvmToolchain(N) resolves N against every
installation on the box, and a cloud image that ships a system JDK 21 and gets
its 17 from Nix has a mixed fleet by construction. A worker forked out of the
store-glibc daemon onto the system JDK inherits the exported LD_LIBRARY_PATH
and dies on the same GLIBC_ABI_DT_X86_64_PLT — one hop further down than
compose-ai-tools#3690, with a clean session environment and a poisoned daemon
one. The wrapper cannot reach that hop, because Gradle canonicalises a detected
toolchain back to the real JDK and forks its launcher straight past the shim. So
compose also writes a Gradle init script,
$GRADLE_USER_HOME/init.d/cooee-desktop-gl.init.gradle, which retunes
LD_LIBRARY_PATH on each forked Test / JavaExec from the JDK that fork will
actually run on: the store dir is removed for a system JVM, and added for a
store JVM — which is the one thing the wrapper could never do. It does that once
the task graph is ready rather than in a doFirst, because Gradle hashes task
actions into the build cache key: an added action would make every Test and
JavaExec miss a remote build cache that CI filled. Skip it with
COOEE_NO_GRADLE_INIT=1.
tools is the same idea for the long tail of CLIs that don't deserve their own
module — each parameter is a nixpkgs attribute name, installed through the same
idempotent nix_ensure:
curl -fsSL -g 'https://env.coo.ee/tools[ripgrep,jq,gh]' | bash
# mix with anything else
curl -fsSL -g 'https://env.coo.ee/tools[ripgrep,jq],node,skills' | bash
Nested attributes work too (tools[nodePackages.prettier]); an unknown name
just warns and is skipped, so one typo doesn't fail the whole environment.
A CLI gets its own module only when installing it needs logic beyond a
nixpkgs attribute name — e.g. playwright (npm + browsers + env vars). Everything
else is the long tail the tools module already covers: pick the nixpkgs
attribute name and request it. The high-demand picks, by job:
| Job | tools[...] request | nixpkgs attributes |
|---|---|---|
| Search / navigate | tools[ripgrep,fd,fzf,tree] | ripgrep (rg), fd, fzf, tree |
| JSON / YAML / data | tools[jq,yq,jaq] | jq, yq (yq-go), jaq |
| GitHub / git | tools[gh,git,lazygit,delta,gitleaks] | gh, git, lazygit, delta, gitleaks |
| HTTP / APIs | tools[curl,wget,httpie,websocat] | curl, wget, httpie (http), websocat |
| Containers / k8s | tools[docker-client,kubectl,k9s,kubernetes-helm] | docker-client, kubectl, k9s, kubernetes-helm (helm) |
| Cloud / IaC | tools[awscli2,terraform,opentofu] | awscli2 (aws), terraform, opentofu (tofu) |
| Build / make | tools[just,gnumake,cmake,ninja] | just, gnumake (make), cmake, ninja |
| Editors / nicer shell | tools[neovim,bat,eza,zoxide,direnv] | neovim (nvim), bat, eza, zoxide, direnv |
| JS/TS formatters | tools[nodePackages.prettier,nodePackages.pnpm] | nested nodePackages.* |
These are advisory — any other nixpkgs
attribute works the same way. Mix them with anything else, e.g.
tools[ripgrep,jq,gh],node,playwright.
android installsThe android module installs a complete, buildable SDK — platform-tools
(adb/fastboot), cmdline-tools, the requested platform(s) and
build-tools — using nixpkgs
androidenv, and points
ANDROID_HOME / ANDROID_SDK_ROOT at it. Add android-emulator and the same
build also includes the emulator and matching system-images. So
curl -fsSL -g 'https://env.coo.ee/java[21],android[34],android-emulator[34]' | bash
leaves you with a JDK, adb, the API-34 platform + build-tools, the emulator,
an API-34 system image, and a configured /dev/kvm — ready for
./gradlew assembleDebug or to boot an AVD, no extra flake step.
Which versions. The bracketed params pick the platform API levels
(android[30,36,wear-33]); a wear-NN level also pulls the android-wear
system image. A bare android installs one default platform.
A level is NN or NN.M, and the minor form is not cosmetic: from API 37
on, Google publishes platforms only under a minor-versioned id, and androidenv
keys them exactly as Google does — 36, 36.1, 37.0, 37.1, with no bare
37. So API 37 is android[37.0]; android[37] asks for a key that doesn't
exist and the SDK build fails with "The version 37 is missing in package
platforms" (the module warns first and suggests the .0 spelling). A param
that names no level at all is reported rather than dropped — silently installing
the default is how a container provisions "successfully" with an SDK that can't
build the project.
Build-tools follow a newer platform. COOEE_ANDROID_BUILD_TOOLS is a
floor: request a platform newer than it and the revision is raised to that
platform's major (android[37.0] → build-tools 37.0.0), because AGP resolves
a default buildToolsVersion from compileSdk and fails with "Failed to find
Build Tools revision …" when the SDK carries only an older one. Older platforms
keep the default, and an explicit COOEE_ANDROID_BUILD_TOOLS is honoured exactly.
Three knobs tune the rest (set them in the environment before running):
| Variable | Default | Selects |
|---|---|---|
COOEE_ANDROID_DEFAULT_PLATFORM | 36 | platform API level for a param-less android (takes NN.M too) |
COOEE_ANDROID_BUILD_TOOLS | 36.0.0 (floor) | the build-tools revision to install; pin it to override the raise above |
COOEE_ANDROID_NCURSES5_STUB | 1 | on x86_64, stub the legacy 32-bit ncurses5 androidenv drags in (set 0 only if you need the 32-bit legacy build-tools on a 32-bit-capable host) |
On x86_64 Linux, androidenv always pulls in 32-bit (i686) glibc/zlib/ncurses5
for ancient 32-bit build-tool binaries the modern 64-bit build-tools don't use.
The niche ncurses5 (ncurses-abi5-compat) usually isn't in the binary cache, so
Nix builds it — and any i686 build runs a 32-bit builder, which fails with
Exec format error on kernels without 32-bit x86 support (common in minimal cloud
containers). By default the module swaps in a native empty stub so the SDK build
never needs a 32-bit builder; COOEE_ANDROID_NCURSES5_STUB=0 restores the real lib.
swift installsSwift has two install sources, and neither one covers every case:
| official toolchain (default) | swift[nix] | |
|---|---|---|
| Version | current: whatever swift.org ships (6.4.0 as of 2026-09), or swift[6.3] / .swift-version | 5.10.1, which is all nixpkgs has; no Swift 6 language mode and no Swift Testing |
| Install hosts | www.swift.org (release list, signing keys), download.swift.org (swiftly + toolchain) | cache.nixos.org only |
| System packages | yes: the toolchain links against the distro's libcurl, libxml2, libz3, libedit, libpython3, … | none |
swift build / swift run | ✓ | ✓ |
swift test | ✓ | ✗: nixpkgs ships no libIndexStore, so SwiftPM can't discover tests |
Default: swiftly, swift.org's own
toolchain manager and its documented Linux install path. The module downloads
swiftly, runs swiftly init --skip-install --no-modify-profile, then
swiftly install <version> --use. With no bracket parameter it reads the
project's .swift-version, the same file swiftly uses to pick a toolchain;
otherwise it installs latest. SWIFTLY_HOME_DIR, SWIFTLY_BIN_DIR and
PATH go into the env profile. swiftly checks toolchain signatures with gpg
and falls back to --no-verify if gpg isn't installed.
The toolchain is a prebuilt tarball, not a Nix closure, so it needs the
distro's shared libraries. swiftly writes the exact apt-get/dnf/yum
command for this distro to a post-install file. The module runs that file as
root (directly, or through sudo). This also means the distro mirror must be
reachable (e.g. archive.ubuntu.com). If it can't run the file, it prints the
command and carries on. Set COOEE_SWIFT_SYSTEM_DEPS=0 to only print it.
download.swift.org is not on the default allowlists of the cloud sandboxes
(www.swift.org often is), so on a locked-down network the preflight stops
here and names that host. Either allow it, or request swift[nix].
swift[nix] is the fallback for Swift 5 packages on a network that only
reaches the Nix cache. nixpkgs' Swift is built to run inside a Nix build:
its compiler wrapper gets the Foundation/Dispatch/XCTest include and link flags
from the NIX_* variables that stdenv setup hooks export. SwiftPM's compiled
manifest also finds libdispatch.so only through the library path. If you
install it plainly into a profile, swift build fails at the manifest
(libdispatch.so: cannot open shared object file) and then again at
import Foundation. So the module:
buildEnv behind a GC root
(~/.local/share/coo-ee/swift-nix/toolchain);mkShell would export
(nix print-dev-env) once, into swift-nix/env.sh;swift-nix/bin/). Each shim
sources that file and then execs the real binary.The flags and the store LD_LIBRARY_PATH stay inside Swift's own processes.
They never reach the session environment, where a store library breaks any
system binary that loads it (the compose lesson).
Binaries built this way run without either.
For Package dependencies SwiftPM git clones each one, usually from
github.com, so that is the build host to allow. Codex's base image selects
Swift with CODEX_ENV_SWIFT_VERSION, and an existing swift that matches the
request is adopted as-is. A Swift 5 image does not satisfy swift[6.4]. The
recommender suggests tools[swiftlint,swiftformat]; both build on Linux from
the Nix cache.
Every Nix-building module resolves nixpkgs through the flake registry, which on a
Determinate install is a rolling ref. The revision it lands on is logged once per
run (nixpkgs: nixpkgs -> <rev>), because it decides what gets built and nothing else
in a failure log names it.
That matters in one specific way. If the ref resolves to a revision
cache.nixos.org has not finished building, nothing substitutes and Nix compiles the
closure from source — and the symptom looks nothing like the cause: an Android SDK
provision that dies with bash-5.3p15.drv failing to build has neither an SDK problem
nor a network problem. When a failure names a source build of something unrelated
(bash, glibc, ncurses), pin a cached revision and re-run:
COOEE_NIXPKGS_REF=github:NixOS/nixpkgs/nixos-25.05
| Variable | Default | Selects |
|---|---|---|
COOEE_NIXPKGS_REF | nixpkgs (registry, rolling) | the nixpkgs flake ref every module builds against |
Because the SDK is built through Nix, it is reproducible and survives
nix store gc (the build is anchored by a GC root under ~/.cache/coo-ee/). On
a box that already has a complete SDK (a CI runner, or a warm box with
platforms + build-tools), the module adopts it instead of rebuilding — and only
falls back to a Nix build if an explicitly requested platform is missing.
Adoption also covers an SDK an image shipped but never exported: when neither
ANDROID_HOME nor ANDROID_SDK_ROOT is set, the module probes the conventional
locations (/opt/android-sdk, /usr/local/lib/android/sdk, ~/Android/Sdk, …),
and on a hit exports the vars and puts platform-tools on PATH so adb and
Gradle resolve the SDK — no more "ANDROID_HOME not set" on a box that already
has one. An explicit ANDROID_HOME/ANDROID_SDK_ROOT is honoured as-is and
suppresses probing (so pointing it at an empty dir still forces a fresh build). The
recorded COOEE_ANDROID_PLATFORMS / COOEE_ANDROID_EMULATOR_IMAGES values are
kept as a description of the request.
Allowlist. Installing now fetches the SDK components, so beyond
cache.nixos.org (the Nix closure) the module needs dl.google.com (the
platform / build-tools / system-image sources). Building the project
additionally wants the registries the module lists under "Recommended for
builds" (maven.google.com, packages.jetbrains.team, *.jetbrains.com,
fonts.googleapis.com, fonts.gstatic.com, androidx.dev for AndroidX
snapshots), plus whatever your Gradle build resolves from — commonly
services.gradle.org (the Gradle distribution, which now 307-redirects to a
gradle/gradle-distributions GitHub release, so github.com and the
release-asset CDN — currently release-assets.githubusercontent.com, formerly
objects.githubusercontent.com — must be reachable too, and
downloads.gradle.org for direct/legacy distribution URLs),
repo.maven.apache.org / repo1.maven.org (Maven Central), and jitpack.io.
Gradle JDK toolchain auto-provisioning additionally reaches its vendor APIs
and download hosts (api.foojay.io, api.adoptium.net, api.github.com,
cdn.azul.com, download.java.net; Amazon Corretto lives behind
*.cloudfront.net).
The build-time registries are advisory: the script never probes them and never
fails on them, it just reminds you to allow them before ./gradlew build.
ruby installscurl -fsSL https://env.coo.ee/ruby,postgres,playwright | bash # a typical Rails app
Which Ruby. ruby[3.4] when given; otherwise what the project pins, read the
way rbenv/asdf/Bundler do: .ruby-version (a ruby- prefix is dropped), then a
.tool-versions ruby line, then the Gemfile's ruby "x.y.z"; otherwise any
Ruby 3. A Ruby matches on major.minor: a patch-level difference changes neither
the ABI nor the gems, and nixpkgs can't pin patch levels anyway (when the Gemfile
pins the patch, Bundler enforces it, so the module warns).
Where it comes from, cheapest first:
PATH, if it matches;$RBENV_ROOT, default
/opt/rbenv): Claude Code's image ships several (3.1, 3.2, 3.3), but
ruby-build can't fetch more there because cache.ruby-lang.org is not
allowlisted. Selecting one is a PATH entry: free and offline;nixpkgs#ruby_<major>_<minor>).Whichever Ruby wins, if its gem dir isn't writable (a Nix-store Ruby, or a
distro Ruby for a non-root user), gems go to a GEM_HOME under
~/.local/share/gem/.
Gems. With a Gemfile in the project, the module runs bundle install
(best-effort, like the npm prefetch, COOEE_NO_DEPS=1 to skip; options via
BUNDLE_* env vars, never bundle config --local into the checkout). Before
that it installs the system headers the bundle's native extensions compile
against, so pg doesn't die with "Can't find the 'libpq-fe.h' header":
pg → libpq-dev, mysql2 → default-libmysqlclient-dev, psych →
libyaml-dev, via apt as root (or sudo). A gem the lockfile resolves to a
precompiled build for this platform (pg 1.6+, nokogiri) needs nothing.
postgres runsThe binaries are rarely the problem. Claude Code's image ships postgresql-16,
but the server binaries sit in /usr/lib/postgresql/16/bin, off PATH; the
cluster is stopped; nothing runs it on session start; and the server refuses to
run as root, which is the user in the sandbox. So postgres:
postgres[16]) and puts
them on PATH, or installs nixpkgs#postgresql[_<major>];initdb'd once under /var/lib/coo-ee/postgres/<major>
(as root; ~/.local/share/coo-ee/postgres otherwise), run by the postgres OS
user when you're root, listening on localhost only with trust auth,
with superuser roles and databases for both you and postgres. A bare
psql, createdb, bin/rails db:prepare, and a database.yml with or
without username: postgres all work without passwords;postmaster.pid from the previous container (its PID possibly reused) is
cleared first;PGHOST (the socket dir) and PGPORT, which libpq clients use
when the config names no host: psql, the pg gem, Rails. It deliberately
does not set DATABASE_URL, because Rails merges it into whichever
environment is running, so bin/rails test would hit the development database.| Variable | Effect |
|---|---|
COOEE_PG_PORT | Port for the dev server (default 5432). |
COOEE_PG_BASE | Where clusters live (default /var/lib/coo-ee/postgres as root, else ~/.local/share/coo-ee/postgres). |
The playwright module installs
the Playwright agent CLI — the playwright-cli tool published as
@playwright/cli — together with
the browsers it drives:
curl -fsSL https://env.coo.ee/playwright | bash # @latest
curl -fsSL -g 'https://env.coo.ee/playwright[0.1.13]' | bash # pin the CLI version
Is it on Nix? Two halves, two answers (and a third case: browsers the box already has):
@playwright/cli is a young (0.1.x) npm package and isn't
in nixpkgs, so it can only come from npm. That's why the module implies
node (the agent CLI needs Node 18+ / npm). To avoid the classic
npm install -g failure where npm's global prefix is the read-only Nix
store, the module points npm at a writable ~/.npm-global prefix
(NPM_CONFIG_PREFIX) and puts ~/.npm-global/bin on PATH.nixpkgs#playwright-driver.browsers ships
Chromium/Firefox/WebKit with their shared-library closure. The module
builds that (anchoring a GC root under ~/.cache/coo-ee/ so it survives
nix store gc, exactly like the Android SDK) and exports
PLAYWRIGHT_BROWSERS_PATH at the store path, plus
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 and
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1. So no apt/install-deps
step and nothing fetched from the Playwright CDN — the only install hosts are
cache.nixos.org (browsers) and registry.npmjs.org (the CLI).Behind a sandbox proxy (Claude Code on the web), Chromium trusts only its
built-in roots plus ~/.pki/nssdb, not the CA env vars every other tool reads.
The script imports the extra/proxy CAs there on every run, whichever module
brought the browser — see COOEE_NO_NSS_CA under
Cloud built-ins. Launch with
proxy: { server: process.env.HTTPS_PROXY }; never ignoreHTTPSErrors.
This split is deliberate: the npm CLI tracks upstream releases, while the heavy, OS-coupled browser binaries come from Nix where their dependencies are pinned and cached.
Browsers the box already has win. When PLAYWRIGHT_BROWSERS_PATH already
names a directory with a complete Chromium — Claude Code's image ships
/opt/pw-browsers — the module adopts it: no Nix build, no download. Only
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS is reserved for the Nix browsers
(whose libraries live in their closure), so adopted browsers don't print a
"skipping validation" notice on every launch.
The project's pinned Playwright gets its browser. Every Playwright release
looks for exactly one build per browser (chromium-1223, …) and refuses to
launch when it's missing, even though an adjacent Chromium drives it fine. A
sandbox has one fixed revision (the image's, or nixpkgs'), and the Playwright CDN
is often not allowlisted — so on Claude Code's image a project pinning
@playwright/[email protected] (wants chromium-1223; the image has chromium-1194)
fails every browser test with "Executable doesn't exist". So
PLAYWRIGHT_BROWSERS_PATH points at a view
(~/.cache/coo-ee/playwright-browsers-view): symlinks to every browser in the
real source dir, plus, for each Playwright on the box (the project's
node_modules/playwright-core and the agent CLI's own), the revision it wants:
cdn.playwright.dev
is reachable (into ~/.cache/coo-ee/playwright-browsers-downloads, beside the
view, because playwright install garbage-collects browser dirs it doesn't
know, and in the view those are the links to the source browsers), elseCOOEE_PLAYWRIGHT_ALIAS=0.The source dir (an image's /opt/pw-browsers, a Nix store path) is never written
to. Alignment re-runs on the already-provisioned fast path, so a lockfile bump
between sessions is picked up, and a real download later replaces an alias.
Two more defaults for tools that drive that Chromium: the agent CLI defaults to
the branded Google Chrome channel (/opt/google/chrome), which neither images nor
Nix ship, so PLAYWRIGHT_MCP_BROWSER=chromium is set when there is no Google
Chrome; and with ruby in the request, BROWSER_PATH (read by Ferrum / Cuprite
for Rails system tests) points at ~/…/playwright-browsers-view/.cooee-chrome, a
link to the newest real Chromium.
The version-drift escape hatch. Playwright expects the browser revisions that
match its bundled core; if the nixpkgs playwright-driver revision ever drifts
from the CLI's, set COOEE_PLAYWRIGHT_DOWNLOAD_BROWSERS=1 to skip the Nix
browsers and let the CLI download its own into PLAYWRIGHT_BROWSERS_PATH instead
(this then needs cdn.playwright.dev on the allowlist and the OS libraries
Playwright's browsers link against). The module also falls back to this path
automatically if the Nix browser build fails.
| Variable | Effect |
|---|---|
COOEE_PLAYWRIGHT_DOWNLOAD_BROWSERS=1 | Skip the box's / Nix browsers; let playwright-cli install-browser download them (needs cdn.playwright.dev + OS libraries). |
COOEE_PLAYWRIGHT_ALIAS=0 | Never alias a nearby Chromium for a pinned Playwright whose exact build can't be downloaded; its browser tests then fail to launch. |
Version selection. The CLI is a tool an agent runs, not a dependency a
project locks (that role belongs to @playwright/test in the repo's own
package.json), so the default is @latest — like gh or curl. An
explicit playwright[0.1.13] pin overrides it, for reproducible installs and
as a second escape hatch when @latest ships a core whose browser revision the
nixpkgs driver lacks. The precedence is simply explicit param > latest.
The project's own Playwright version is not chosen here; it's whatever its lockfile pins. The view above is what makes that pin work against the browsers the box has.
Agent skills. The CLI ships optional skills for coding agents
(playwright-cli install --skills). Those are per-project, so the module doesn't
run it for you — invoke it inside the repo where you want the skills. (For
Claude Code skills, see the skills module instead.)
In a hosted agent environment the cheapest install is the one you skip. The script is built around two ideas:
PATH (a warm box, or shipped by the provider's base
image). If so it adopts the existing tool — sets JAVA_HOME / GOPATH /
etc. from it — and skips the Nix install. If nothing is left to install, even
Nix (base) and the host preflight are skipped, so the whole run is offline.CODEX_ENV_*_VERSION. When you
request a module Codex could provide but the tool isn't present, the script
warns you to set that variable instead of installing a parallel copy via
Nix. Claude Code and Gemini expose their tools directly on PATH, so there
the adopt path covers it.Knobs:
| Variable | Effect |
|---|---|
COOEE_FORCE=1 | Ignore the short-circuit and adoption; force a fresh Nix install / repair. |
COOEE_IGNORE_HOST_CHECK=1 | Continue even if a required install host is blocked. |
COOEE_NO_ACTIVATE=1 | Skip auto-activation — don't touch shell rc files or ~/.claude/settings.json. |
COOEE_NO_CHECKOUT_PERMS=1 | Don't merge the side-by-side project checkouts' permissions.allow into the global Claude config (see activation). |
COOEE_CHECKOUTS_DIR | Workspace root holding the side-by-side checkouts to seed Gradle wrappers from, pin sdk.dir in (android), and scan for permissions.allow (default: the project dir's parent). |
CLAUDE_CONFIG_DIR | Override the global Claude config dir the SessionStart hook is written into (default ~/.claude). |
COOEE_REPLACE=1 | Make this request replace the box's previous one instead of adding to it (the hook, stamp and persisted env then carry only this request). |
COOEE_NO_DEPS=1 | Skip build-dependency prefetch — install the toolchain only, don't resolve the project's dependencies. |
COOEE_GRADLE_DEPS_TASK | Run a specific Gradle task for the prefetch (e.g. assemble -x test) instead of the default whole-graph artifact resolution. |
COOEE_NO_GRADLE_INIT=1 | Don't write the Maven mirror init script, nor $GRADLE_USER_HOME/init.d/cooee-desktop-gl.init.gradle, the init script that gives the Compose Desktop GL libs to each forked JVM that can load them and withholds them from each one that cannot — see . Implied by . |
The devenv.sh backend is selected per request with the
?devenv query flag, not an environment variable.
The provisioning stamp lives at ~/.config/coo-ee/provisioned (the canonical
module set); delete it to force the next run to re-probe.
Skills and the environment-wide guides (today the build-brief
usage rules) only help an agent that reads them, and each agent reads its own
home:
| Agent | Skills | Global rules |
|---|---|---|
| Claude Code | ~/.claude/skills/ | ~/.claude/CLAUDE.md ($CLAUDE_CONFIG_DIR) |
| Codex | ~/.agents/skills/ | $CODEX_HOME/AGENTS.md (default ~/.codex/AGENTS.md), or AGENTS.override.md when that is non-empty |
| Gemini CLI, Antigravity | ~/.agents/skills/ | ~/.gemini/GEMINI.md, plus the file context.fileName names in the system or ~/.gemini/settings.json when that differs |
| OpenCode | reads Claude Code's | reads Claude Code's |
Claude Code's home is always written. Codex and Gemini are added when their CLI
is on PATH, their config dir exists, or the run detected them as the
provider; COOEE_AGENTS replaces that
detection. OpenCode needs nothing of its own because it reads Claude Code's
home, but it scans ~/.agents/skills/ too: on a box with OpenCode beside Codex
or Gemini it sees each skill twice. Set COOEE_AGENTS=none there if that
matters more than the other agents' copy.
A skill directory another installer already owns (npx skills, the
compose-preview installer) is kept rather than replaced. A repo that carries a
skill twice (yschimke/skills keeps generated copies under plugins/ for its
marketplace) has each name linked once, from the copy outside plugins/.
A skill an enabled Claude Code plugin already ships (the compose-skills
bundle from yschimke/skills or compose-agent-plugins' marketplace, say) is not
linked into ~/.claude/skills/, and a link an earlier run left there is
removed: the plugin and the link would load every skill twice. The plugin's
skills are read from Claude Code's plugin cache, or from the cloned repo's
plugins/<bundle>/ before the first session fills that cache. Codex and Gemini
still get their ~/.agents/skills/ copy.
Antigravity 1.2.12 does not load ~/.agents/skills/; install skills there as
plugins (see compose-agent-plugins).
The SessionStart hook and the permission allowlist under
Auto-activation stay Claude Code only: Codex cloud runs its
setup script once per container and reads the persisted env from ~/.bashrc,
and neither Codex nor Gemini takes a Claude-style allowlist.
By default each module installs its package straight into the default Nix
profile (nix profile install nixpkgs#…). Add the ?devenv query flag and the
server renders the script with the devenv.sh backend
instead:
curl -fsSL 'https://env.coo.ee/java,node?devenv' | bash
The picker exposes this as a Use devenv.sh toggle, which appends ?devenv=1
to the request URL — the module list (and thus the path) is unchanged; only the
backend differs. The choice is resolved at render time: the renderer splices
in one of two backend driver fragments (modules/_backend-nix.sh or
modules/_backend-devenv.sh) right after the header, so the rendered script
carries only the selected backend's code — there is no run-time if devenv
branch. Each fragment implements the same contract — nix_ensure plus the
cooee_backend_* hooks the modules call — so a module fragment never needs to
know which backend it's running under. With the devenv fragment spliced in:
base installs Nix as usual, then its cooee_backend_setup hook installs the
prebuilt nix profile install nixpkgs#devenv and seeds a minimal devenv
project under ~/.config/coo-ee/devenv (a devenv.yaml pinning
nixpkgs-unstable, plus a git repo).nix_ensure nixpkgs#<pkg> appends the attr to a generated
devenv.nix (packages = [ pkgs.<pkg> … ]); devenv builds the environment
and its profile bin (the stable .devenv/profile symlink) is prepended to
PATH, now and in the persisted env, so tools resolve exactly as the
Nix-profile backend's do.We write a real devenv.nix rather than using devenv's newer ad-hoc
--option packages:pkgs flag, so it works on any devenv version and avoids the
fresh-directory edge cases ad-hoc invocations hit.
Everything else — adoption of already-present tools, host preflight, env persistence, auto-activation, and build-dependency prefetch — is unchanged.
The Android SDK is provisioned through a backend hook
(cooee_backend_android_sdk) rather than nix_ensure: the nix backend builds an
androidenv expression directly, while the devenv backend writes a native
android integration into
devenv.nix (plus nixpkgs.allow_unfree in devenv.yaml) and lets devenv build
it, then reads back the ANDROID_HOME the integration exports. Both honour the
i686/ncurses5 stub overlay. The remaining direct nix build is Playwright's
browsers, which needs the store out-path to set PLAYWRIGHT_BROWSERS_PATH —
something a buildEnv/profile doesn't surface — so it stays a direct build under
either backend. devenv builds resolve faster when its binary cache
(devenv.cachix.org) is reachable, but fall back to building from
cache.nixos.org, which is already required.
An end-to-end CI job (.github/workflows/devenv.yml) keeps this honest. A first
job renders a go,java[17,21],node?devenv script and runs it on a clean runner —
installing Nix, installing devenv, generating devenv.nix, building the
environment — then asserts the three tools resolve from the devenv profile, the
JDK collapse fired, the npm build-dependency prefetch ran against the
devenv-provided node, and a re-run short-circuits. A second job
(devenv-android) renders android[34],java[17]?devenv, empties ANDROID_HOME
to force the build path, and asserts the generated devenv.nix carries an
android block, devenv.yaml allows unfree, and adb resolves from the
devenv-built SDK in the Nix store.
Limitations. A single devenv profile is one buildEnv, so it can't hold two
JDKs at once (the per-install --priority the nix-profile backend uses isn't
available): a multi-version request like java[17,21] — including the
java+android default of 17,21 — falls back to the first major with a
warning.
The same module selection can drop a Dev Container
into your repo instead of provisioning the current box. This is for real Dev
Container consumers — VS Code Dev Containers, GitHub Codespaces, the
devcontainer CLI, JetBrains, and GitHub Copilot. It is not for Claude Code
web sessions, which ignore .devcontainer/ entirely (they run behind a
managed agent proxy whose allowlist is set at environment creation, not from
repo files). In the picker it is therefore a target of its own — an
alternative to Claude / Codex / GitHub — not a toggle layered onto them.
Add the ?devcontainer query flag (alias ?format=devcontainer) and the server
returns an apply script — pipe it to bash and it writes
.devcontainer/devcontainer.json:
# from your repo: writes .devcontainer/devcontainer.json
curl -fsSL 'https://env.coo.ee/java,android?devcontainer' | bash
# inspect the raw file without writing anything
curl -fsSL 'https://env.coo.ee/java,android?devcontainer=json'
The apply script targets the git repo root when run inside one (else the
current directory; override with COOEE_DEVCONTAINER_DIR) and refuses to
overwrite an existing devcontainer.json unless COOEE_FORCE=1 — the same
force convention the installer uses. The file is embedded via a quoted heredoc,
so nothing in it is shell-expanded.
This is the thin strategy (option A): rather than re-expressing each module
as Docker layers, the generated config picks a mainstream base image and runs
the existing curl … | bash one-liner as its postCreateCommand, so all the
provisioning logic — the Nix/devenv backends, cloud built-in adoption,
idempotent repair — is reused verbatim. The module list, canonical form, and
CDN cache key are identical to the shell render; only the artifact differs (the
same separation as ?devenv). Implementation lives in
api/env/devcontainer.js.
mcr.microsoft.com/devcontainers/base:ubuntu; ?base=codex (alias
?image=codex) targets ghcr.io/openai/codex-universal:latest, the image
Codex cloud mirrors and that this service already aligns with via the
CODEX_ENV_* selectors. The maintained Claude Code dev container
Feature
(ghcr.io/anthropics/devcontainer-features/claude-code) is layered on so the
CLI is present in-container.need_host/want_hosts; the renderer unions them (the same list the shell
script probes and the picker shows) into containerEnv.COOEE_ALLOWED_DOMAINS
— a minimal, correct egress allowlist to paste into the host's network policy
(Claude Code init-firewall.sh, Codex /etc/codex/allowed_domains.txt, or
the Codex cloud / Claude web allowlist UI).?devenv rides along into the one-liner, so the devcontainer uses the same
backend the shell render would.?devcontainer=image renders a richer apply script that writes a four-file
.devcontainer/ bundle instead of a lone devcontainer.json:
curl -fsSL 'https://env.coo.ee/java,android?devcontainer=image' | bash
| File | Purpose |
|---|---|
devcontainer.json | Builds the Dockerfile, grants --cap-add=NET_ADMIN/NET_RAW, provisions at postCreate, and runs the firewall at postStart. |
Dockerfile | Adds the firewall tooling (iptables, ipset, dnsutils, sudo) to the host-affinity base (BASE build arg). |
init-firewall.sh | Default-deny egress firewall. Resolves the allowlist to IPs while egress is still open, builds an ipset, then drops all output except loopback, established traffic, DNS, the docker network, and the allowed IPs. Modeled on the Claude Code / Codex reference firewalls. |
allowed-domains.txt | The egress allowlist — concrete hosts active (one per line, resolved + enforced), wildcard hosts listed as advisory comments (the IP firewall can't match by name). |
Unlike option A, this enforces egress in-container rather than only
surfacing the host list. The firewall is generated from the same
need_host/want_host metadata; opt out by removing the runArgs caps and the
postStartCommand. The static templates live in modules/_devcontainer-*
(kept out of the catalog by the _ prefix, so they're lint/bash -n-checkable).
Still thin underneath: provisioning still runs at postCreate (so it
executes as the remote user with a correct PATH). Baking provisioning into a
build-cached RUN layer — the true per-module-layer translation — is the next
step; it needs the Nix-store / build-user story validated in a live container
(no Docker in CI yet, so the bundle is currently verified by generation +
bash -n, not a real build).
JAVA_TOOL_OPTIONSThree cloud fixes need JVM system properties: the sandbox proxy (the JVM ignores
http(s)_proxy), the extra CA truststore (a Nix JDK ignores the system trust
store), and -Dfile.encoding=UTF-8. They are applied through two channels,
and the split is deliberate.
JAVA_TOOL_OPTIONS is set for the provisioning run and written to
~/.config/coo-ee/env.sh (export KEY=<shell-quoted>), so any shell that
sources the profile gets it. It is not forwarded to a harness env file, and
it is restored to its entry value before the script exits.
The reason is that harnesses replay their env files as unquoted shell, one
KEY=value line at a time — Claude Code inlines the file as the preamble of
every Bash call. JAVA_TOOL_OPTIONS is unavoidably space-separated, and its
nonProxyHosts value is pipe-separated, so the line stops being one assignment:
JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=p -Dhttp.proxyPort=8080 -Dhttp.nonProxyHosts=a|b
# ^ assignment ^ "command not found" ^ …and a pipeline of more
That sprays dozens of command not found lines into the output of every
command for the rest of the session. Restoring the variable matters as much as
not forwarding it: a harness snapshot is a diff of what the hook changed, so a
variable left exactly as found never enters the preamble at all — which is also
the only way to keep the container's own (equally space-laden) value out of it.
Gradle — the consumer that actually needs these flags — gets them from
org.gradle.jvmargs in the user-dir gradle.properties instead. That file is
parsed as properties rather than shell (spaces are simply fine), needs no
environment inheritance, and reaches the client, its daemon, and every forked
worker, including a daemon started by an IDE or a bare ./gradlew that never saw
the provisioning shell. Opt out with COOEE_NO_GRADLE_PROPS=1.
Adding a new JVM flag? Route it through cooee_add_jvm_flag so it reaches both
channels; a bare add_env JAVA_TOOL_OPTIONS … would reach only the profile.
Installing the toolchain is only half the job — the first real build still has to download the project's dependencies, and in a sandbox that's exactly when egress may be locked down. So once a language module finishes, it warms the build cache while the registries are still reachable:
java resolves every resolvable configuration's files across all
projects (via a transient init script), so the dependency artifacts — not
just the metadata that the dependencies report task alone fetches — land in
the Gradle cache, along with the Gradle distribution and plugins, without
compiling. It prefers the project's ./gradlew (pinned version) over any
system gradle. Set COOEE_GRADLE_DEPS_TASK to run a specific task instead
(e.g. assemble -x test).node runs npm ci when there's a clean lockfile (falling back to
npm install), otherwise npm install.ruby apt-installs the headers the bundle's native gems need, then runs
bundle install (see Ruby).The step targets the consuming project ($CLAUDE_PROJECT_DIR, else the current
directory) and is a no-op when there's no Gradle build / package.json / Gemfile there.
It is best-effort: a failure (e.g. a registry host that isn't allowlisted)
warns but never fails provisioning, since the toolchain itself is already in
place. Opt out entirely with COOEE_NO_DEPS=1.
The trivial Gradle project under examples/gradle-sample/
exercises this end-to-end: the prefetch
workflow renders the java module, runs it against the sample, and asserts the
dependency JAR is downloaded into the Gradle cache (and that COOEE_NO_DEPS=1
skips it).
Cloud sessions leave through a few shared egress IPs, Maven Central rate-limits
per IP, and every new session starts with an empty dependency cache, so builds
fail with "Received status code 429 from server: Too Many Requests" even when
no single build downloads much. The java module writes two things into the
Gradle user home, so every checkout on the box gets them without a change to any
repository:
$GRADLE_USER_HOME/init.d/cooee-maven-mirror.init.gradle puts Google's
Maven Central mirror (maven-central.storage-download.googleapis.com) first in
every repository list that declares Central: plugin, settings, buildscript and
project repositories. Central stays behind it for anything too new to be
mirrored yet. When the same list also declares Google Maven, Google's groups
(androidx, com.android, Play services, Material, Firebase) are kept off
Central, the mirror and the Gradle Plugin Portal: the portal proxies to Central,
so a gradlePluginPortal() listed before google() would otherwise send AGP's
own dependencies there first, and a 429 fails the build where a 404 would have
moved on. androidx is kept off them even without Google Maven declared.
A list that never declares Central gains no repository.$GRADLE_USER_HOME/gradle.properties:
systemProp.org.gradle.internal.network.retry.max.attempts=6 and
…initial.backOff=2000. Gradle already retries a 429, but by default only 3
times from a 1s backoff. A value you set yourself is left alone.Both run before the prefetch, so it benefits too.
Opt out of the mirror with COOEE_NO_MAVEN_MIRROR=1 (the retries stay); point it
elsewhere with COOEE_MAVEN_MIRROR_URL; tune the retries with
COOEE_GRADLE_RETRY_ATTEMPTS / COOEE_GRADLE_RETRY_BACKOFF_MS.
The same init script routes builds through a private Maven cache when the
environment configures one: COO_MAVEN_URL (the cache's base URL),
COO_MAVEN_USER and COO_MAVEN_TOKEN. It reads them when Gradle runs, not when
the module provisions, so the token is never written to disk and a variable set
only in CI or in a cloud provider's environment settings still applies. Nothing
in the script names a host.
With all three set, the build's own Central, Google Maven and Gradle Plugin
Portal repositories resolve through <base>/central, <base>/google and
<base>/gradle-plugins with those credentials. Each keeps its place and its
content filters, so a build only ever reaches the upstreams it already declares.
The GCS mirror is not added, since the cache already absorbs Central's rate
limit. Other repositories (jitpack, …) stay direct. With any of the three unset,
builds behave exactly as described above. When COO_MAVEN_URL is set at
provision time, its host also joins the network-access check.
In GitHub Actions:
env:
COO_MAVEN_URL: ${{ vars.COO_MAVEN_URL }}
COO_MAVEN_USER: ${{ vars.COO_MAVEN_USER }}
COO_MAVEN_TOKEN: ${{ secrets.COO_MAVEN_TOKEN }}
In a cloud agent sandbox, set the same three in the provider's environment settings.
build-briefAn agent session runs check and full render pipelines in-session, and those
bury their one real line in thousands. So when the java module is provisioning
for Gradle, it also installs
build-brief (static-var/build-brief,
MIT, a single Go binary with no runtime deps): it keeps the full log on disk and
prints only what decides the next move — failed tasks, failed tests, warnings,
build scan URLs, artifact paths — and passes Gradle's exit code through
unchanged, so it is safe anywhere a bare ./gradlew was.
"For Gradle" means one of: a Gradle wrapper in one of the side-by-side
checkouts (the same scan that seeds the wrapper distribution — so the two can
never disagree), gradle already on PATH, or tools[gradle] in the request. A
Maven-only or plain-JDK request installs nothing, since the reducer only reduces
Gradle.
The binary comes from the GitHub release (checksum-verified against the
release's SHA256SUMS; a mismatch refuses the install), falling back to
upstream's bb.staticvar.dev/install.sh when that download is blocked. Like the
dependency prefetch, the whole step is best-effort: Gradle still builds
without the reducer, so a blocked CDN warns and provisioning continues.
It also installs the part that makes the tool actually get used: a usage
guide, written as a managed block in each agent's global rules file — the
global Claude config's CLAUDE.md ($CLAUDE_CONFIG_DIR/CLAUDE.md, default
~/.claude/CLAUDE.md), plus Codex's and Gemini's when they are on the box (see
Agent homes) — which every session in the container loads,
whichever checkout it opens. The block is
delimited by markers and rewritten in place, so re-provisioning updates it
rather than stacking copies, and surrounding content is preserved. It carries
build-brief's own per-command rules plus two environment-specific ones: prefer a
repo's own Gradle launcher when it ships one (a shared-host wrapper like
compose-ai-tools' scripts/agent-gradle.sh wraps build-brief and requires it
on PATH — which is the other reason to provision it here), and don't run
build-brief --install in a checkout, because that rewrites the repo's
git-tracked AGENTS.md. Dirtying provisioned working trees is what this project
avoids everywhere else (see Auto-activation), so the rules
live in the environment instead.
The step also runs on the already-provisioned fast path. The stamp only
proves each module's tool is on PATH, and the SessionStart hook re-runs the
one-liner every session — so on a box provisioned before this existed, the fast
path is the common case and build-brief would otherwise never arrive. It is a
command -v when there is nothing to do.
Bash(build-brief:*) is pre-approved along with the rest of the JVM toolchain.
| Variable | Effect |
|---|---|
COOEE_NO_BUILD_BRIEF=1 | Skip the whole step |
COOEE_BUILD_BRIEF=1 | Install even when no Gradle build was detected |
COOEE_BUILD_BRIEF_VERSION=x.y.z | Pin a release (default: latest); a different version already on PATH is replaced rather than adopted |
COOEE_BUILD_BRIEF_BIN_DIR=dir | Install location (default ~/.local/bin) |
COOEE_NO_BUILD_BRIEF_GUIDE=1 | Install the binary but write no guide |
Persisting the env to ~/.config/coo-ee/env.sh doesn't help if nothing loads
it. So a normal run also wires up activation for the consuming project — no
manual source, no per-project boilerplate:
Shell rc. A guarded block is appended to ~/.bashrc, ~/.profile (and
~/.zshrc when zsh is in play) that sources the persisted env, so every
future shell has the toolchain on PATH. The block is fenced by
# >>> coo.ee/env >>> markers and added at most once.
Claude Code SessionStart hook + permissions. The script installs (or
merges) a SessionStart hook into the global Claude config —
~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json) — that
re-runs the same request —
curl -fsSL https://env.coo.ee/<your,modules> | bash — so a fresh web session
re-provisions automatically. It reconstructs the request (modules + params)
from the run itself, merges with jq/python3/node so existing settings
are preserved (and warns with the snippet if none is available), and is
idempotent — the hook is added at most once. The global config is part of the
environment (the container's home) and applies to every session no matter
which repo it opens, so a single write provisions the toolchain everywhere.
Deliberately no project checkout is touched: a repo's
.claude/settings.json is git-tracked, and writing into it would dirty a
committed file and nag the agent to commit environment plumbing that doesn't
belong in the repo.
In the same write it seeds a permission allowlist (permissions.allow)
for the toolchain it just installed, so the agent isn't prompted the first
time it runs the obvious commands — Bash(gradle:*) / Bash(java:*) for
java, Bash(npm:*) / Bash(npx:*) for node, Bash(adb:*) for android,
and so on. A tools[...] request allowlists each binary it installs (so
tools[ripgrep] grants Bash(rg:*)). Rules are unioned into any existing
permissions.allow — never removing what's already there — and deduped, so
re-runs and hand-added rules coexist. Each module declares its own rules via
provides_perms in modules/.
The allowlist also carries two things that aren't tied to any toolchain:
Requests are cumulative. A box is usually provisioned more than once with
different requests: the environment's setup asked for java,android, then a
Ruby project runs ruby,postgres,playwright. The second request adds to the
first instead of replacing it:
~/.config/coo-ee/request, or read back from
an older run's hook) is merged with this one by module, with this request's
params winning for a module both name;…/android,java,postgres,ruby,…). An earlier request's hook is
replaced, and hooks that aren't coo.ee's are left alone;JAVA_HOME, their PATH entries) is
kept, and the stamp names every module, so the next session's merged hook
takes the fast path.Before this, the second request truncated the persisted env (fresh shells lost
JAVA_HOME) and added a second hook. The two hooks then overwrote each other's
stamp on every session, so neither ever took the fast path. Set
COOEE_REPLACE=1 to make a request replace the previous one, e.g. to drop
modules.
This is generic plumbing baked into the bootstrapper, so any project that
pulls in coo.ee/env gets it — nothing is specific to one repo. GitHub Actions
has its own activation ($GITHUB_ENV / $GITHUB_PATH), so the step is skipped
there. Opt out entirely with COOEE_NO_ACTIVATE=1.
The served script is just a concatenation (plus, for parameterized requests, a
small injected set_params block between the header and the modules):
_header.sh + [set_params ...] + base.sh + <module>.sh ... + _footer.sh
The modules/ directory holds the source fragments — the single
source of truth. api/env/render.js is the renderer the
live service goes through, so there is only one way to produce a script. To
render a module set locally and syntax-check it after editing the fragments:
node -e 'process.stdout.write(require("./api/env/render").render("java,android").body)' | bash -n -
render() canonicalizes the module list — it dedupes, forces base first, and
sorts the rest alphabetically — so java,android and android,java render
byte-identically (the android block comes before the java block in both).
Parameterized modules canonicalize the same way: their bracketed params are
deduped and sorted, and a module named twice merges its params, so
skills[b,a] and skills[a,b] (and skills[a],skills[b]) all share one cache
entry. To preview a different combination, pass a different segment to
render().
Given what you've already picked, the service can suggest what to add next —
sibling modules and tools[...] bundles that commonly go with your selection:
# JSON, for tooling / a UI
curl -fsSL https://env.coo.ee/recommend/java
# human-readable, for the terminal
curl -fsSL -H 'Accept: text/plain' https://env.coo.ee/recommend/java
{
"selected": ["base", "java"],
"recommendations": [
{ "kind": "module", "spec": "android", "score": 5,
"reasons": ["Android builds run on the JDK you just added."] },
{ "kind": "tools", "spec": "tools[gradle,kotlin]", "score": 3,
"reasons": ["Gradle + Kotlin are the usual JVM build/CLI companions."] }
// ... plus the universal CLI kit and agent skills
],
"next": "base,android,java,skills,tools[bat,fd,fzf,gh,gradle,jq,kotlin,ripgrep]"
}
Each spec is an appendable request, and next is the whole selection plus
every suggestion, already canonicalized — so curl .../$next | bash just works.
A CI check asserts every recommendation (and next) actually renders, so the
service can never suggest something uninstallable.
The rules live in api/env/recommend.js as a static
affinity table (BASELINE_TOOLS, per-module RULES, COOCCURRENCE). The
engine takes the affinities as an argument — recommend(segment, { knowledge })
— so the hand-written table can later be swapped for one derived from real
usage (co-occurrence counts from request logs) without touching the scoring.
Because it is one idempotent line, it drops into either layer:
Cloud setup script (Claude Code on the web environment, Codex setup script) — runs once, result is cached:
curl -fsSL https://env.coo.ee/java,android | bash
Global config / SessionStart hook (~/.claude/settings.json) — runs in
both local and cloud sessions; idempotency keeps it cheap. You usually don't
write this by hand: the first run auto-installs exactly this
hook into the global Claude config. The shape it writes (or merges) is:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command",
"command": "curl -fsSL https://env.coo.ee/java,android | bash" } ] }
]
},
"permissions": {
"allow": [
"Bash(./gradlew:*)", "Bash(adb:*)", "Bash(gradle:*)", "Bash(java:*)"
]
}
}
GitHub Actions — GitHub is just another cloud target. Use the bundled composite action:
- uses: yschimke/coo-ee-env@v1
with: { modules: java,android }
- run: ./gradlew build # JAVA_HOME etc. already exported
or drop to the raw step (identical effect):
- run: curl -fsSL https://env.coo.ee/java,android | bash
The rendered script is runner-aware: add_env forwards JAVA_HOME /
ANDROID_HOME / JAVA_TOOL_OPTIONS to $GITHUB_ENV, so the toolchain is on
PATH for every later step; each module is wrapped in a collapsible ::group::
and failures surface as ::error:: annotations. On a non-root runner base.sh
brings up the nix-daemon so the root-owned store is reachable (see
Modules). .github/workflows/env-example.yml
is a runnable example — workflow_dispatch-only, since the action exercises the
live service rather than a branch's code, so gate per-commit on a local render
instead (as setup.yml does).
Either way, make sure the module hosts above are on your environment's
allowlist — the script tells you precisely which are missing if not. This is
the same allowlist discussed in the skills repo's
compose-preview/references/agent-cloud.md.
modules/ shell fragments — the single source of truth
api/env/render.js pure renderer: canonicalize + concatenate (unit-testable)
api/env/[modules].js Vercel handler wrapping render()
api/env/recommend.js pure recommender: affinity table + scoring (unit-testable)
api/env/recommend/[modules].js Vercel handler wrapping recommend()
api/modules.js JSON module catalog (name + software blurb) for the picker
public/index.html landing page: autocomplete picker -> the one-liner
vercel.json routes /env/:modules, /recommend/:modules, /:modules
render() sorts + dedupes modules and always puts base first, so
java,android and android,java produce byte-identical output and share one
CDN cache entry; bracketed params (skills[a,b]) are canonicalized the same
way. Unknown modules — or malformed names/params — return 400 with the
available list.
Visiting env.coo.ee/ (no module segment) serves a
gitignore.io-style picker
(public/index.html): type to autocomplete modules, add
them as chips, and copy the generated curl … | bash one-liner (or follow
View script to read it inline). It builds the command against
location.origin, so the copied line works from production, a Vercel preview,
or vercel dev alike, and the selection round-trips through the URL hash
(/#java,android) so a configuration is shareable.
Below the one-liner the picker shows the required hosts for the current
selection — the union of every active module's need_host declarations
(base plus your picks plus anything they imply), deduped and each with the
reason it's needed. A small target toggle (Claude / Codex / GitHub /
Devcontainer) is the environment you're setting up. For the cloud agents it
tailors the guidance and copy format: Copy hosts drops the bare hostnames
straight into the chosen target's allowlist — newline-separated for Claude Code
(Network access → Custom → Allowed domains) and GitHub Actions egress policies,
or comma-separated for Codex's domain allowlist, which wants a single CSV line.
The Devcontainer target is different in kind: it switches the one-liner to
the devcontainer apply script (for real Dev
Containers — VS Code / Codespaces / Copilot — not Claude Code web) and lists the
hosts that get baked into the generated bundle. The choice is remembered across
visits. A collapsible Recommended for builds list adds the want_host
entries — the registries a build needs but the install doesn't — with a Copy
all for the full set. This is the same host set the rendered script probes and
prints on a blocked install, surfaced up front so you can configure egress
before the first run.
The picker's module list isn't hardcoded — it fetches
/api/modules, which derives the catalog (each module's
name + software: blurb, the params it takes, what it implies, and its
need_host/want_host set) straight from the modules/ fragments. Add or
edit a fragment and the picker updates with no extra wiring. base is shown as
a fixed, always-included chip. Static files are matched before the /:modules
rewrite, so / and /api/* resolve to the page and the catalog while
/java,android still falls through to the renderer.
Run it locally:
node -e 'process.stdout.write(require("./api/env/render").render("java,android").body)' | bash -n -
npx vercel dev # serves http://localhost:3000/env/java,android
CI runs two checks on each push/PR:
render.yml renders every module set and
syntax-checks the output, so a fragment that breaks bash -n never lands.setup.yml renders android,java and
actually runs it on a clean runner, then asserts java, adb, and nix
work from the persisted profile and that a second run is a no-op — an
end-to-end check that the setup really installs a usable environment.Two complementary suites, mirrored by two CI workflows:
test/, node --test, run by
render.yml) — the pure
renderer/recommender: canonicalization, params, implications, recommendations.
No browser, no network.tests/,
@playwright/test, run by
e2e.yml) — the browser behaviour of the
landing-page picker (public/index.html): autocomplete, chip selection, the
live curl … | bash one-liner, the host allowlist, clipboard copy, and
#hash sharing, plus committed screenshot baselines.npm test # renderer unit tests (node --test)
npm run test:e2e # Playwright UI tests + screenshot comparison
npm run serve # serve the app locally on :3000 (the picker + /api/*)
tests/server.js is a tiny stand-in for vercel dev (which needs a Vercel
login): it serves public/ and mounts the real api/** handlers with the same
routing as vercel.json, so the picker behaves identically with no account or
secrets. Playwright boots it automatically via the webServer block in
playwright.config.js.
Screenshots. Each meaningful state yields two images: a committed
visual-regression baseline in
tests/__screenshots__/ (compared on every run, so a
UI change that alters pixels fails CI) and a labelled artifact PNG in
screenshots/ (living documentation of the UI). Because
baselines are font/OS-sensitive, both the CI job and the baselines run inside
the matching Playwright container (mcr.microsoft.com/playwright:v<version>)
so rendering is identical. Regenerate them the same way after an intentional UI
change:
docker run --rm --ipc=host --network=host -v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.60.0-noble npm run test:e2e:update
java,android artifact — zero infrastructure, demonstrating the
contract — now removed in favour of the renderer below.api/) A Vercel Node
function parses /env/:modules, canonicalizes the list, concatenates the
modules/ fragments, and streams text/x-shellscript.env.coo.ee/<modules> (see "Domain" below).env.coo.ee/ autocompletes the
module list and generates the one-liner (see Landing page).Deployed on Vercel behind env.coo.ee. These were the one-time account-level
steps (no secrets live in the repo), kept here for reference:
main → production deploy; PRs → preview
URLs. Vercel auto-detects the api/ functions and vercel.json routes.env.coo.ee, then in
DNS add CNAME env -> cname.vercel-dns.com.env.coo.ee/<modules> is the recommended shape:
curl | bash. Keep the apex coo.ee for a human landing page.Accept: a
browser (Accept: text/html) gets text/plain so the script displays in the
tab for review instead of downloading, while curl and other tools get the
semantically correct text/x-shellscript. Both send
Content-Disposition: inline (no save-as), and Vary: Accept keeps the two
representations cached separately. HTTPS only./java,android@v1) for reproducible installs.dl.google.com |
| — |
android-emulator | Adds emulator + system-images to the SDK (via the implied android build) and configures /dev/kvm access (GitHub 99-kvm4all.rules); android-emulator[36,wear-33] picks the image levels; implies android | cache.nixos.org, dl.google.com | — |
node | Node.js 22 LTS, npm | cache.nixos.org, registry.npmjs.org | Codex: CODEX_ENV_NODE_VERSION |
playwright | Playwright agent CLI (@playwright/cli, the playwright-cli binary) via npm + browsers: the box's own (Claude Code's /opt/pw-browsers) when present, else from Nix; serves the browser revision the project's pinned Playwright expects (downloaded, else aliased — see Playwright); playwright[0.1.13] pins the CLI version; implies node | cache.nixos.org (only without box browsers), registry.npmjs.org; cdn.playwright.dev advisory (exact builds for a pinned Playwright) | COOEE_PLAYWRIGHT_ALIAS=0 to never alias |
python | CPython 3 + pip | cache.nixos.org, pypi.org, files.pythonhosted.org | Codex: CODEX_ENV_PYTHON_VERSION |
go | Go toolchain, GOPATH | cache.nixos.org, proxy.golang.org, sum.golang.org | Codex: CODEX_ENV_GO_VERSION |
rust | rustc + cargo | cache.nixos.org, static.crates.io, index.crates.io | Codex: CODEX_ENV_RUST_VERSION |
ruby | Ruby + RubyGems + the project's gems (bundle install); the version from ruby[3.4], else the project's .ruby-version / .tool-versions / Gemfile ruby, else 3 — adopting a matching Ruby on PATH or in the image's rbenv before installing from Nix; apt-installs headers for native gems (pg → libpq-dev, mysql2, psych) — see Ruby | cache.nixos.org (only without a matching Ruby), rubygems.org, index.rubygems.org | Codex: CODEX_ENV_RUBY_VERSION |
postgres | A running local PostgreSQL dev server: adopts the box's server binaries (Debian's /usr/lib/postgresql/<major>/bin, off PATH) or installs from Nix; owns a trust-auth cluster on localhost with superuser roles for you and postgres; restarted every session; exports PGHOST/PGPORT; postgres[16] picks the major — see PostgreSQL | cache.nixos.org (only when the box has no PostgreSQL) | COOEE_PG_PORT, COOEE_PG_BASE |
swift | The official swift.org toolchain (swift, swiftc, SwiftPM, swift-format, sourcekit-lsp) via swiftly, plus the distro packages it links against; the version comes from swift[6.3], else the project's .swift-version, else the latest release. swift[nix] installs nixpkgs' much older Swift 5.10.1 instead (build/run only, see Swift) | www.swift.org, download.swift.org (cache.nixos.org for swift[nix]); builds: github.com | Codex: CODEX_ENV_SWIFT_VERSION |
compose | Jetpack Compose @Preview rendering: installs the compose-preview and compose-ui-builder agent skills (render previews to PNG with no emulator; author and export UI Builder designs) and puts the compose-preview CLI on PATH ($COMPOSE_PREVIEW_HOME, default ~/.local/share/compose-preview, so an MCP host can start compose-preview mcp serve), pulls in a JDK + the Android SDK, and provisions the native GL libs (libGL/libX11/fontconfig/libstdc++) Compose Desktop (skiko/Skia) loads at render time, baked into a wrapper JDK (plus a Gradle init script that retunes each fork's LD_LIBRARY_PATH per JDK) rather than the session environment; implies java, android | github.com, cache.nixos.org (git + the GL libs) | COOEE_COMPOSE_SKILLS (space-separated) to change the skills; COOEE_NO_DESKTOP_GL=1 to skip GL; COOEE_DESKTOP_GL_PACKAGES to adjust the set; COOEE_NO_GRADLE_INIT=1 to skip the init script |
dotfiles | A config repo cloned and applied to $HOME; dotfiles[owner/repo] (optionally @ref) — required, there is no default repo. Applies by linking the top-level dotfiles (backing up anything it would clobber to *.cooee.bak), or via GNU Stow when that's already installed and the repo is a package tree. A repo-provided install.sh is not run unless COOEE_DOTFILES_RUN_INSTALL=1 | github.com (cache.nixos.org if git is absent) | COOEE_DOTFILES_RUN_INSTALL=1 to allow the repo's installer |
skills | Agent skills, linked into ~/.claude/skills/ and, when Codex or Gemini is on the box, ~/.agents/skills/ (see Agent homes); skills[owner/repo] links every skill in a repo, skills[owner/repo/<skill>] links just one | github.com (cache.nixos.org if git is absent) | — |
tools | Arbitrary CLI tools from nixpkgs, by name (tools[ripgrep,jq,gh]) | cache.nixos.org | — |
COOEE_NO_GRADLE_PROPS=1COOEE_NO_MAVEN_MIRROR=1 | Don't write $GRADLE_USER_HOME/init.d/cooee-maven-mirror.init.gradle, which resolves from Google's Maven Central mirror ahead of Central (and retires one an earlier run wrote) — see Maven Central rate limits. The network-retry settings still apply. Also skipped by COOEE_NO_GRADLE_INIT=1. |
COOEE_MAVEN_MIRROR_URL | The Central mirror the init script puts first (default https://maven-central.storage-download.googleapis.com/maven2/). |
COO_MAVEN_URL, COO_MAVEN_USER, COO_MAVEN_TOKEN | A private Maven cache. With all three set when Gradle runs, the init script resolves Central, Google Maven and the plugin portal through it — see A private Maven cache. |
COOEE_GRADLE_RETRY_ATTEMPTS / COOEE_GRADLE_RETRY_BACKOFF_MS | Gradle network retries written to the user-dir gradle.properties (default 6 / 2000); a value already there wins. |
COOEE_NO_GRADLE_PROPS=1 | Don't pin the cloud JVM flags on org.gradle.jvmargs in the user-dir gradle.properties ($GRADLE_USER_HOME/gradle.properties). The java module writes them there (merge-safe, never overriding a property you set yourself): the proxy host/port + nonProxyHosts, the extra-CA truststore, and -Dfile.encoding=UTF-8. This is the primary channel for those flags — see JVM flags and JAVA_TOOL_OPTIONS. |
COOEE_NO_NSS_CA=1 | Don't import the extra/proxy CAs into the browser NSS database at ~/.pki/nssdb. By default every run (fast path included) does, whenever extra CAs exist: Chromium on Linux — Playwright's, or one the provider's image ships — ignores SSL_CERT_FILE, NODE_EXTRA_CA_CERTS and the system store, so behind a TLS-terminating sandbox proxy every HTTPS page fails with net::ERR_CERT_AUTHORITY_INVALID without it. certutil comes from nixpkgs#nss.tools when it isn't on PATH. |
COOEE_SWIFT_SYSTEM_DEPS=0 | swift: print the distro packages the official toolchain links against instead of installing them as root. |
COOEE_NO_COMPOSE_CLI=1 | compose: link the skill but don't install the compose-preview CLI; the skill then bootstraps it on first use. |
COOEE_AGENTS | Which agents besides Claude Code get skills and the environment guides: a comma list of codex, gemini, or none. Replaces the detection described in Agent homes. |
COOEE_BASE_URL | Service base URL baked into the installed SessionStart hook (default https://env.coo.ee). |
basesend_latercreate_triggerClaude_Code_Remoteclaude-code-remotecreate_pull_requestupdate_pull_requestadd_reply_to_pull_request_commentget_job_logssubscribe_pr_activitypermissions.allow applied. The activation step scans the workspace
root's checkouts, reads each .claude/settings.json's permissions.allow,
and unions those rules into the global config — so a repo's pre-approvals
hold no matter which sibling the session opens, still without writing into
any tracked tree. Opt out with COOEE_NO_CHECKOUT_PERMS=1; point the scan
at a specific workspace root with COOEE_CHECKOUTS_DIR.