Files
opencode-devbox/AGENTS.md
T
Joakim Persson ba8000732d
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 10s
Validate / validate-omos (push) Successful in 4m15s
Validate / validate-base (push) Successful in 5m2s
Publish Docker Image / build-base (push) Successful in 30m33s
Publish Docker Image / smoke-base (push) Successful in 3m20s
Publish Docker Image / smoke-omos (push) Successful in 4m24s
Publish Docker Image / build-variant-base (push) Successful in 13m37s
Publish Docker Image / build-variant-omos (push) Successful in 30m18s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 14s
v2.1.0: symlink OMOS bundled skills from image, bump opencode 1.17.4->1.17.5
Deploy the five oh-my-opencode-slim bundled skills (clonedeps, codemap,
deepwork, oh-my-opencode-slim, simplify) by symlinking them from the image
path into ~/.agents/skills/ on every container start, instead of the
installer copying them into the persistent config volume on first run only.
Image-sourced links mean 'docker compose pull' + recreate refreshes the
skills with no installer run and no config reset; the old copy-on-first-run
froze them in the volume forever.

- entrypoint-user.sh: new non-fatal OMOS bundled-skills reconcile block
  (runs after skillset deploy so OMOS wins the simplify collision; absolute
  symlinks; gated by OMOS_SKILLS, now independent of ENABLE_OMOS). Both
  installer calls now pass --skills=no. One-time migration backs up (never
  deletes) frozen real copies in ~/.config/opencode/skills/ to .bak.<epoch>.
- scripts/smoke-test.sh: assert the bundled-skills source path on omos.
- Bump OPENCODE_VERSION 1.17.4 -> 1.17.5.
- Versioning: document the move to independent image semver (v2.0.0 was the
  decouple point), mirroring pi-devbox. README/AGENTS/.env.example/CHANGELOG
  updated; new docs/omos-skills.md.
2026-06-13 22:32:09 +02:00

25 KiB

AGENTS.md

Project overview

Docker image packaging opencode into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).

pi was removed in v2.0.0 (deprecated since v1.17.2). The INSTALL_PI build arg, the with-pi / omos-with-pi / pi-only variants, the base-pi-only published tag, and all ~/.pi-related wiring are gone. pi now ships from its own repo (joakimp/pi-devbox). Do not add pi functionality here. The removal history + the NPM_CONFIG_PREFIX relocation (~/.pi/npm-global~/.config/opencode/npm-global, with a one-time migration shim in entrypoint-user.sh) are recorded in docs/CLEANUP-v2.0.0.md and the v2.0.0 CHANGELOG entry.

File roles

  • Dockerfile.base — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as joakimp/opencode-devbox:base-<sha12>. Rebuilt only when its content hash changes.
  • Dockerfile.variantFROMs the base and adds only opencode/omos installs gated by build args: INSTALL_OPENCODE (default true), INSTALL_OMOS, and INSTALL_MEMPALACE. All GitHub-sourced binaries are pinned with version ARGs. Two variants: base (INSTALL_OPENCODE=true) and omos (+INSTALL_OMOS=true).
  • entrypoint.sh — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via .devbox-owner sentinel when ownership is already correct). Then drops to developer via gosu.
  • entrypoint-user.sh — runs as developer: git config, opencode.jsonc generation (delegated to generate-config.py), LAN-access setup (delegated to setup-lan-access.sh), a one-time npm-global prefix migration shim (legacy ~/.pi/npm-global~/.config/opencode/npm-global), skillset auto-deploy from mounted skillset repo, OMOS bundled-skills reconcile (symlinks the image's bundled skills into ~/.agents/skills/), OMOS config setup.
  • rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via host.docker.internal resolution) and generates a writable ~/.ssh-local/config using the host as an SSH jump; no-op on native Linux. Controlled by DEVBOX_LAN_ACCESS / HOST_SSH_USER / DEVBOX_HOST_ALIAS / DEVBOX_LAN_AUTOJUMP_PRIVATE. Ships the mechanism only (generic host jump alias); user targets stay host-side — named-peer ProxyJump host overrides go in a bind-mounted ~/.config/devbox-shell/ssh-lan.conf (Included before ~/.ssh/config), never baked into the image. Scoping invariant: every Include in the generated config MUST be preceded by a bare Host * reset — an Include is scoped to the enclosing Host/Match block, so without the reset the included config only applies when targeting host/mac and named peers fall back to SSH defaults. The top Host * block also overrides UserKnownHostsFile and ControlPath into the writable ~/.ssh-local sidecar (first-value-wins), because the bind-mounted ~/.ssh is read-only — otherwise multiplexed hosts (ControlPath ~/.ssh/cm/...) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances base-latest.
  • rootfs/usr/local/lib/opencode-devbox/generate-config.py — generates ~/.config/opencode/opencode.jsonc from env vars. Never overwrites an existing config (checks both .json and .jsonc). Auto-registers MCP servers for detected tools (mempalace via mempalace-mcp, gitea-mcp, context7 remote endpoint).
  • scripts/smoke-test.sh — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
  • scripts/generate-dockerhub-md.py — generates DOCKER_HUB.md from a hand-maintained HUB_TEMPLATE constant. --check fails if the committed file is out of sync (enforced by the validate workflow).
  • DOCKER_HUB.mdauto-generated from HUB_TEMPLATE in scripts/generate-dockerhub-md.py. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
  • README.md — authoritative source documentation for everything in this repo. Independent of DOCKER_HUB.md: the Hub doc is hand-maintained in the generator's HUB_TEMPLATE and intentionally slim, linking back to the gitea README for depth.
  • .gitea/README.mdread this first if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
  • .gitea/workflows/validate.yml — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
  • .gitea/workflows/docker-publish-split.yml — production CI pipeline on tag push (v*). Two-phase split-base: computes base hash, conditionally builds base, runs 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes base-latest alias, updates Docker Hub description.

Versioning scheme

Image tags follow independent semver — they version this image, not the bundled opencode release. v2.0.0 is the decoupling point (the pi-removal breaking release); from there the opencode npm version is tracked in CHANGELOG.md and the OPENCODE_VERSION ARG but no longer drives the tag. This mirrors the sibling joakimp/pi-devbox repo, which decoupled from the pi tool version at its own v1.0.0.

  • MAJOR — breaking changes to how users run/configure the container (volume layout, removed variants/build-args, an entrypoint contract change that requires user action). v2.0.0 (pi removal + npm-prefix relocation) is the reference example.
  • MINOR — backward-compatible features: new variants/tags, new opt-in behavior, new env vars, or changed-but-compatible semantics. Example: v2.1.0 added the OMOS bundled-skills image-symlink mechanism.
  • PATCH — opencode/tool version bumps and small fixes that don't change the contract. When a release pairs a tool bump with a feature, the feature wins and it's a minor.
  • Pre-flight check — whenever an opencode bump is part of the release, verify it is real before claiming it in the CHANGELOG:
    npm view opencode-ai version           # must equal the X.Y.Z you pin in Dockerfile.variant
    
    Historical note: under the old v{opencode_version}[letter] scheme a mismatched tag was a namespace hazard — e.g. v1.15.12 was cut while opencode was still 1.15.11, then re-cut as v1.15.11c (2026-05-28), costing a CI cycle. Semver tags no longer encode the opencode version, so that specific collision class is gone — but a CHANGELOG that names the wrong upstream version is still wrong.

CI produces four Docker Hub tags under opencode-devbox per release: vX.Y.Z, latest, vX.Y.Z-omos, latest-omos — one tag pair (versioned + floating alias) per variant (two variants: base, omos).

When bumping the opencode version, bump OPENCODE_VERSION in Dockerfile.variant and update the comment in .env.example if it names a specific model/version for context.

Upstream sources — where to look up release notes

When drafting a release CHANGELOG entry, pull notes from the canonical upstream repo for each tracked package. Getting this wrong leads to thin or wrong release notes; the image bytes are unaffected but the documentation suffers.

Package Canonical upstream What you'll find there
opencode-ai (npm) https://github.com/anomalyco/opencode/releases Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not.
Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) Each project's own GitHub releases page Usually less material per release; quote selectively.

Trap to avoid: there is a github.com/sst/opencode repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use anomalyco/opencode for opencode release notes.

Fetch pattern (saved here for muscle memory):

# Latest stable opencode-ai versions on npm
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'

# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'

Critical conventions

  • entrypoint.sh volume ownership loop — when adding a new named volume mount point, add it to the for dir in ... loop in entrypoint.sh so root-owned volumes get chowned on startup. The loop writes a .devbox-owner sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.

  • Documentation coupling on release — four docs co-vary and drift in lockstep when not updated together:

    • README.md is the source of truth for user-facing build/run/config detail.
    • DOCKER_HUB.md is auto-generated from HUB_TEMPLATE in scripts/generate-dockerhub-md.py. CI's --check run fails if it's stale. Hub-facing copy is intentionally slim (~5.5 kB, ~78% headroom against the 25 kB Hub limit) — update the template here when image variants, quick-start flow, or the elevator pitch change. README.md no longer feeds into Hub, so README edits do NOT require regenerating DOCKER_HUB.md.
    • CHANGELOG.md records every release. When cutting a tag, promote ## Unreleased to ## vX.Y.Z[n] — YYYY-MM-DD BEFORE pushing the tag so the tag points at a CHANGELOG that names itself. Keep entries reverse-chronological (newest at top, after the Unreleased block). Doc-only updates that happen post-tag (Hub description live-patches, README clarifications) get a fresh ## Unreleased block with a note that they don't trigger a new image build.
    • AGENTS.md (this file) carries domain facts that change on structural releases — tag-count statements, CI job lists, install contracts. After any change to .gitea/workflows/*.yml or the variant matrix, grep this file for stale numbers (grep -nE "four|eight|all [0-9]").
    • .env.example must be hand-updated to match Dockerfile/entrypoint behavior — it is not auto-generated.

    Release-day checklist: README → (regenerate DOCKER_HUB.md only if HUB_TEMPLATE changed) → promote CHANGELOG Unreleased → grep AGENTS.md for stale counts → commit → tag → push tag.

    Between releases the same coupling applies. Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or generate-config.py refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit.

  • GitHub/Gitea-sourced binaries float by default — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to latest. Each build-time install step reads the /releases/latest Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same ARCH case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses x64, bat/eza/zoxide use x86_64/aarch64, gosu uses amd64/arm64). Intentional pins: OPENCODE_VERSION (drives the image tag), NODE_VERSION=22 (major pin), DEBIAN_VERSION=trixie-slim (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.

  • Resolved versions are logged by the smoke testscripts/smoke-test.sh prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to latest.

  • OMOS_VERSION MUST be passed by CI as a concrete version, not left at the latest default. The npm install step in Dockerfile.variant (oh-my-opencode-slim@${OMOS_VERSION}) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (base-buildcache) the layer gets reused even when latest would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently masked in opencode-devbox by OPENCODE_VERSION being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a vN.N.Nb opencode-version-unchanged release ships that only bumps omos. Preventative fix: .gitea/workflows/docker-publish-split.yml has a resolve-versions job that runs npm view oh-my-opencode-slim version, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the EXPECTED_OMOS_VERSION env var — would catch the regression on the next release rather than several releases later. If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.

  • Registry buildkit cache-export is currently disabled — do NOT re-add cache-from/cache-to to the build-base step in .gitea/workflows/docker-publish-split.yml without first verifying that buildkit's mode=max cache-export to registry-1.docker.io no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on --cache-to while image push worked fine. Failure shape is stable (Offset:0 in the _state token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing :base-buildcache mode=max), and explains why pinning setup-buildx-action@v4.0.0 didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in base-decide and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 Unreleased block for the full diagnostic chain. Manual escape-hatch publish procedure: docs/manual-host-publish.md.

  • Push steps wrap docker buildx build --push in a 3-attempt retry loop (15s, 30s backoff) for transient registry-1.docker.io blips — rate limits, brief 5xx, CDN flap. Implemented as inline shell: bash steps with docker buildx build raw rather than docker/build-push-action@v7 so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in .gitea/workflows/docker-publish-split.yml; smoke-test builds (load: true, no push) are untouched. This does NOT mask deterministic failures — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the ci-release-watcher skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.

  • Shell scripts use set -euo pipefail — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with || true.

  • MemPalace install path — installed via uv tool install into /opt/uv-tools/mempalace/. Both the mempalace CLI and the mempalace-mcp MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use pip install --break-system-packages — that was the previous approach and has been removed. Do not use ["python3", "-m", "mempalace.mcp_server"] in opencode.jsonc — system Python can't import from the uv venv.

  • generate-config.py idempotency — the script MUST never overwrite an existing opencode.jsonc or legacy opencode.json. Config persists in the devbox-opencode-config named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.

  • Skillset auto-deploy — on every container start, entrypoint-user.sh looks for a skillset repo (detection order: $SKILLSET_CONTAINER_PATH$HOME/skillset/workspace/skillset) and runs deploy-skills.sh --bootstrap --prune-stale. This creates relative symlinks in ~/.agents/skills/ and ~/.config/opencode/instructions/. Do NOT bind-mount ~/.agents/skills/ from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume devbox-opencode-config persists the deployed config across restarts.

  • OMOS bundled-skills reconcile — on the omos variant, entrypoint-user.sh symlinks the five skills bundled with oh-my-opencode-slim (clonedeps, codemap, deepwork, oh-my-opencode-slim, simplify) from the image path /usr/lib/node_modules/oh-my-opencode-slim/src/skills/<name> into ~/.agents/skills/, on every start, after the skillset deploy (so OMOS wins name collisions via ln -sfn — the only overlap is simplify, which was removed from the skillset repo). These are absolute symlinks (target is image-internal at a fixed /usr path) — do NOT "fix" them to relative like skillset's. Because the target lives in the image, pulling a newer image updates the skills with no installer run and no config reset. The block is non-fatal ({ … } || true), gated by OMOS_SKILLS (default true, independent of ENABLE_OMOS) and the presence of the source dir (no-op on the base variant). The two oh-my-opencode-slim install calls now pass --skills=no unconditionally — the installer manages only oh-my-opencode-slim.json, never skills; do not reintroduce installer-managed skills. A one-time migration (marker: ~/.config/opencode/.omos-skills-migrated) backs up — never deletes — any frozen real copies the old installer left in ~/.config/opencode/skills/ to <name>.bak.<epoch>, because those would otherwise shadow the fresh image-sourced symlinks. The build-time smoke test asserts the bundled-skills source path exists (catches an upstream package restructure loudly). Full rationale: docs/omos-skills.md.

  • Config persistence via named volumedevbox-opencode-config is a Docker named volume mounted at ~/.config/opencode/. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. Because NPM_CONFIG_PREFIX is set to ~/.config/opencode/npm-global (relocated from the legacy ~/.pi/npm-global in v2.0.0), anything installed via npm install -g as the developer user also lands on this volume and survives container recreate AND image rebuild.

  • npm-global prefix relocation (v2.0.0 breaking change) — the user-writable global npm prefix moved from ~/.pi/npm-global to ~/.config/opencode/npm-global. The old path lived on the devbox-pi-config volume (only mounted in docker-compose.yml); the new path is on devbox-opencode-config, which is a persistent named volume in BOTH docker-compose.yml and docker-compose.shared.yml. entrypoint-user.sh carries a one-time migration shim: if ~/.pi/npm-global exists and the marker ~/.config/opencode/npm-global/.migrated-from-dot-pi is absent, it cp -an the old lib//bin//share/ into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on /usr (the variant Dockerfile runs each npm install -g with NPM_CONFIG_PREFIX=/usr) so the volume mount doesn't shadow them. The ENV NPM_CONFIG_PREFIX/PATH lines in Dockerfile.base are declared after all build-time installs.

  • Default CMD is bash -l — not a harness. docker compose run --rm devbox drops the user into a login shell to choose: aws sso login, then opencode (or any tool). Pass the harness explicitly to launch directly: docker compose run --rm devbox opencode. docker compose exec bypasses entrypoint+CMD entirely (existing user workflow unchanged).

  • Docker Hub description update — uses /v2/auth/token endpoint (not the deprecated /v2/users/login). Auth uses identifier/secret fields, returns access_token, sent as Bearer. Short description must be ≤100 bytes.

CI quirks

  • Both build jobs include an IPv4 preference step (gai.conf + driver-opts: network=host for buildx) to work around intermittent IPv6 failures on the Gitea runners.
  • update-description job runs when the base variant published (needs: [build-variant-base, build-variant-omos], gated with always() + an explicit build-variant-base.result == 'success' check so a partial-publish run still refreshes the Hub description).
  • Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
  • Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
  • Gitea Actions runner has ~40 GB disk, often 70%+ used at job start. All load: true jobs (validate-base, validate-omos, smoke-base, smoke-omos) include a Reclaim runner disk step that strips catthehacker-resident toolchains and prunes stale docker state before setup-buildx-action. Build jobs use a lighter version (push-by-digest doesn't need docker system prune). Don't remove these steps without testing on a fresh runner.
  • docker/build-push-action@v7 with platforms: linux/amd64,linux/arm64 handles multi-arch push natively in a single job — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires actions/{upload,download}-artifact@v4+ which Gitea Actions doesn't support (see below).
  • actions/upload-artifact and actions/download-artifact must stay at @v3 on Gitea. v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with GHESNotSupportedError. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
  • Step scripts run under /bin/sh (dash), not bash. Avoid bash-isms like ${VAR//a/b} parameter-pattern substitution; use POSIX alternatives (tr, sed) or declare shell: bash on the step.
  • BUILDKIT_PROGRESS=plain is set at workflow level on docker-publish-split.yml so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.

Testing changes

The smoke test (scripts/smoke-test.sh) is the canonical check and runs automatically in CI. To run locally:

# Base image
docker compose build
bash scripts/smoke-test.sh opencode-devbox --variant base

# OMOS image
docker build --build-arg INSTALL_OMOS=true -t opencode-devbox:omos .
bash scripts/smoke-test.sh opencode-devbox:omos --variant omos

For manual/exploratory testing:

  1. docker compose run --rm devbox bash
  2. Check specific tools inside: nvim --version, bat --version, uv --version, mempalace --help, etc.
  3. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the .devbox-owner sentinel behavior.
  4. For generate-config.py changes: run standalone with HOME=/tmp/fake OPENCODE_PROVIDER=anthropic python3 rootfs/usr/local/lib/opencode-devbox/generate-config.py.

Commit style

Imperative mood, first line summarizes the change. Multi-line body explains "why" when non-obvious. Examples from history:

  • Fix ownership of named volume mount points in entrypoint
  • Add uv package manager to base image for on-demand Python support
  • Upgrade base image from Debian bookworm to trixie (current stable)