#!/usr/bin/env bash set -euo pipefail # ── SSH ControlMaster socket dir ──────────────────────────────── # Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the # base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this # creates the directory with the right permissions on every container # start. /tmp is per-container so the dir doesn't survive recreation; # baking it into a Dockerfile layer would be wrong. # Mode 700 is required — OpenSSH refuses to use a ControlPath dir that # others can write to. mkdir -p /tmp/sshcm chmod 700 /tmp/sshcm # ── LAN access: generic host-OS-agnostic reachability helper ──────── # On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't # reach the host's directly-attached LAN peers by default; this generates a # writable ~/.ssh-local/config that uses the host as an SSH jump. On native # Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS # (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header. if [ -r /usr/local/lib/opencode-devbox/setup-lan-access.sh ]; then bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true fi # ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent # Respects host bind-mounts and user customizations — existing files # are never overwritten. To restore defaults: rm ~/.bash_aliases (or # .inputrc) and recreate the container, or cp from /etc/skel-devbox/ # directly. SKEL_DIR="/etc/skel-devbox" if [ -d "$SKEL_DIR" ]; then for f in .bash_aliases .inputrc; do if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then cp "$SKEL_DIR/$f" "$HOME/$f" fi done fi # ── v2.0.0 migration: relocate npm global prefix off the legacy ~/.pi path ── # Pre-v2.0.0 images set NPM_CONFIG_PREFIX=~/.pi/npm-global (a pi-specific # path). v2.0.0 removed pi and moved the prefix to # ~/.config/opencode/npm-global, which is a persistent named volume in every # compose layout (the old ~/.pi volume was only in docker-compose.yml). If a # user upgraded with the old ~/.pi volume still mounted, copy their # previously globally-installed npm packages to the new prefix once so they # remain on PATH. The marker keeps this idempotent and a no-op for fresh # installs; the whole block is harmless when the old path is absent. NEW_NPM_PREFIX="$HOME/.config/opencode/npm-global" OLD_NPM_PREFIX="$HOME/.pi/npm-global" MIGRATION_MARKER="$NEW_NPM_PREFIX/.migrated-from-dot-pi" if [ -d "$OLD_NPM_PREFIX" ] && [ ! -f "$MIGRATION_MARKER" ]; then echo "Migrating npm global prefix: ~/.pi/npm-global -> ~/.config/opencode/npm-global" mkdir -p "$NEW_NPM_PREFIX" # cp -n: never overwrite a file already in the new prefix (a freshly # installed package wins over the legacy copy). for sub in lib bin share; do if [ -d "$OLD_NPM_PREFIX/$sub" ]; then mkdir -p "$NEW_NPM_PREFIX/$sub" cp -an "$OLD_NPM_PREFIX/$sub/." "$NEW_NPM_PREFIX/$sub/" 2>/dev/null || true fi done touch "$MIGRATION_MARKER" 2>/dev/null || true fi # ── MemPalace: initialize palace for the workspace if mempalace is installed # Creates the palace directory structure on first run. Idempotent — skips # if palace already exists, so upgrades from older versions preserve # existing data. `--yes` auto-accepts detected entities so the init is # non-interactive — the container entrypoint has no usable stdin for # prompts anyway. if command -v mempalace &>/dev/null && [ -d /workspace ]; then PALACE_DIR="${HOME}/.mempalace" if [ ! -d "$PALACE_DIR/palace" ]; then echo "Initializing MemPalace for workspace (non-interactive)..." # /dev/null 2>&1 || true fi fi # ── Git config defaults ────────────────────────────────────────────── if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then git config --global user.name "$GIT_USER_NAME" fi if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then git config --global user.email "$GIT_USER_EMAIL" fi # ── Generate opencode config from env vars if no config mounted ────── # Delegated to a standalone Python script for clarity and testability. # The script is idempotent: it never overwrites an existing opencode.json # (bind-mounted from host, persisted in named volume, or previously # generated) and no-ops if OPENCODE_PROVIDER is unset. python3 /usr/local/lib/opencode-devbox/generate-config.py # ── Skillset: deploy skills/instructions from mounted skillset repo ── # When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset), # run the deploy script to create relative symlinks for skills and instructions. # This ensures skills resolve correctly inside the container regardless of # where the repo lives on the host. Idempotent — second run is a no-op. # # Detection order: # 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts) # 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose) # 3. /workspace/skillset (skillset is directly inside workspace root) SKILLSET_DEPLOY="" if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh" elif [ -x /workspace/skillset/deploy-skills.sh ]; then SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh" fi if [ -n "$SKILLSET_DEPLOY" ]; then "$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true fi # ── OMOS bundled skills: symlink from the image into the flat skills dir ── # The oh-my-opencode-slim package bundles its skills at a fixed, image-internal # path (npm global prefix /usr — see Dockerfile.variant). Historically the omos # *installer* COPIED them into ~/.config/opencode/skills/ on first run only, # freezing them in the persistent `devbox-opencode-config` named volume: pulling # a newer image never refreshed them, and the only update path was # `OMOS_RESET=true` (which also clobbers the user's hand-tuned opencode config). # # Instead we symlink them from the IMAGE into ~/.agents/skills/ — the same flat # dir skillset uses, which opencode scans (directly and via the ~/.claude/skills # pointer). Because the link targets live in the image, `docker compose pull` + # recreate updates the skills for free: no installer run, no config reset. # # ~/.agents/skills/ is authoritative. The legacy ~/.config/opencode/skills/ real # dir is intentionally bypassed; a one-time migration backs up (never destroys) # the frozen copies it holds so they stop shadowing the fresh image-sourced # skills. The migration marker lives in the parent config dir, not inside # skills/, so it never interferes with that directory's contents. # # Absolute symlink (not relative like skillset): the target is always inside the # container at a fixed /usr path, and ~/.agents/skills/ is an ephemeral # container-layer dir rebuilt each start — there is no host/container path # divergence to guard against. The whole block is non-fatal (`{ … } || true`): # a transient ln/mv failure must never brick container startup, mirroring the # skillset deploy above. Runs AFTER skillset deploy so OMOS wins any name # collision (e.g. `simplify`) via `ln -sfn`. Gated by OMOS_SKILLS (default true) # and the presence of the bundled skills (omos-variant images only). if [ "${OMOS_SKILLS:-true}" = "true" ]; then OMOS_SKILLS_SRC="" for cand in \ /usr/lib/node_modules/oh-my-opencode-slim/src/skills \ /usr/local/lib/node_modules/oh-my-opencode-slim/src/skills; do if [ -d "$cand" ]; then OMOS_SKILLS_SRC="$cand"; break; fi done if [ -n "$OMOS_SKILLS_SRC" ]; then { AGENTS_SKILLS_DIR="$HOME/.agents/skills" OPENCODE_SKILLS_DIR="$HOME/.config/opencode/skills" OMOS_SKILLS_MARKER="$HOME/.config/opencode/.omos-skills-migrated" mkdir -p "$AGENTS_SKILLS_DIR" for skill_path in "$OMOS_SKILLS_SRC"/*/; do [ -d "$skill_path" ] || continue name="$(basename "$skill_path")" # OMOS wins collisions: -f replaces an existing symlink (e.g. skillset's). ln -sfn "${skill_path%/}" "$AGENTS_SKILLS_DIR/$name" # One-time unshadow: back up — never destroy — the frozen real copy the # old installer left in the persistent config volume. `! -L` ensures we # only ever touch a real dir, never a symlink a user/skillset created. if [ ! -f "$OMOS_SKILLS_MARKER" ] \ && [ -d "$OPENCODE_SKILLS_DIR/$name" ] \ && [ ! -L "$OPENCODE_SKILLS_DIR/$name" ]; then mv "${OPENCODE_SKILLS_DIR:?}/$name" \ "${OPENCODE_SKILLS_DIR}/${name}.bak.$(date +%s)" fi done touch "$OMOS_SKILLS_MARKER" 2>/dev/null || true } || true fi fi CONFIG_DIR="$HOME/.config/opencode" OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json" # ── oh-my-opencode-slim setup (multi-agent orchestration) ──────────── # Activated by ENABLE_OMOS=true. Requires the image to be built with # INSTALL_OMOS=true (which installs bun + the oh-my-opencode-slim package). OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json" if [ "${ENABLE_OMOS:-false}" = "true" ]; then if ! command -v bun &>/dev/null; then echo "WARNING: ENABLE_OMOS=true but bun is not installed." echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true" elif [ ! -f "$OMOS_CONFIG" ]; then echo "Setting up oh-my-opencode-slim agents..." # Determine installer flags OMOS_TMUX_FLAG="no" if [ "${OMOS_TMUX:-false}" = "true" ]; then OMOS_TMUX_FLAG="yes" fi # Skills are NOT installer-managed any more — they are symlinked from the # image into ~/.agents/skills/ by the OMOS bundled-skills block above # (gated by OMOS_SKILLS). Always pass --skills=no so the installer never # writes frozen copies into the persistent config volume. bun x oh-my-opencode-slim@latest install \ --no-tui \ --tmux="${OMOS_TMUX_FLAG}" \ --skills=no echo "oh-my-opencode-slim configured successfully." else echo "oh-my-opencode-slim config found at $OMOS_CONFIG (use OMOS_RESET=true to overwrite)." # Allow reset via env var (creates backup automatically) if [ "${OMOS_RESET:-false}" = "true" ]; then echo "OMOS_RESET=true — regenerating oh-my-opencode-slim config..." OMOS_TMUX_FLAG="no" [ "${OMOS_TMUX:-false}" = "true" ] && OMOS_TMUX_FLAG="yes" bun x oh-my-opencode-slim@latest install \ --no-tui \ --tmux="${OMOS_TMUX_FLAG}" \ --skills=no \ --reset fi fi fi # ── Execute command ────────────────────────────────────────────────── exec "$@"