Add mage dev-env task for isolated worktree dev environments (#70602)

* Add worktree dev environment tooling

Add `mage dev-env` task for isolated worktree dev environments with
Docker containers and deterministic per-worktree ports. Includes workmux
layout with nREPL dev server, frontend build, and nvim panes. Adds a
custom Claude Code Docker image with Clojure tooling via mise, and
a bin/claude-dangerous helper script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move dev-env mage task to personal -edpaget-dev-env task

Move the worktree dev environment tooling from the shared mage task
namespace (mage.dev-env) to a personal task namespace (mage.edpaget.dev-env),
remove the sample .workmux.example.yaml, and update .workmux.yaml hooks
to use the new command name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Paget
2026-03-10 16:01:59 -05:00
committed by GitHub
parent 5cd8e165fa
commit c6a2880b31
6 changed files with 483 additions and 2 deletions

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@
/.env*
!/.env.example
/.envrc
/.workmux.yaml
/.lein-env
/.lein-failures
/.lein-plugins

18
bb.edn
View File

@@ -533,4 +533,20 @@
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
:examples [["mage -escherize-fix-whitespace '**/*.clj{,c,s}'" "Removes all lines like ' ' from all clj files"]]
:arg-schema [:sequential [:string {:name "glob-pattern" :description "Glob pattern to match files"}]]
:task (task! (fix-whitespace/run-clean parsed))}}}
:task (task! (fix-whitespace/run-clean parsed))}
-edpaget-dev-env
{:doc "Stand up (or tear down) an isolated dev environment for this worktree"
:examples [["./bin/mage -edpaget-dev-env --app-db postgres" "Postgres app-db with auto ports"]
["./bin/mage -edpaget-dev-env --app-db postgres --with mysql --with mongo" "Full stack"]
["./bin/mage -edpaget-dev-env" "Minimal: just port config (H2 app-db)"]
["./bin/mage -edpaget-dev-env --down" "Tear down containers for this worktree"]
["./bin/mage -edpaget-dev-env --status" "Show running containers and ports"]]
:requires [[mage.edpaget.dev-env :as dev-env]]
:options [[nil "--app-db DB" "App database: h2 (default), postgres, mysql" :default "h2"]
[nil "--with SERVICE" "Warehouse service (repeatable): postgres, mysql, mariadb, mongo, clickhouse, ldap, maildev"
:multi true :default [] :update-fn conj]
[nil "--down" "Tear down all containers for this worktree"]
[nil "--status" "Show current env status"]
[nil "--slot SLOT" "Override port slot (0-99)" :parse-fn Integer/parseInt]]
:task (task! (dev-env/dev-env! parsed))}}}

96
bin/claude-dangerous Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Runs Claude Code in a custom Docker container with --dangerously-skip-permissions.
# The image includes Clojure, Java, Babashka, and all tools from mise.toml.
# DB connection info is forwarded from mise.local.toml.
#
# Usage: workmux add --agent ./bin/claude-dangerous feat/my-branch
#
# Pass --build as the first arg to force a rebuild of the image.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IMAGE_NAME="metabase-claude-code"
DOCKERFILE="$PROJECT_ROOT/dev/docker/claude-code/Dockerfile"
# Build image if --build passed or image doesn't exist
if [[ "${1:-}" == "--build" ]]; then
shift
echo "Building $IMAGE_NAME image..."
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
elif ! docker image inspect "$IMAGE_NAME" &>/dev/null; then
echo "Image $IMAGE_NAME not found, building..."
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
fi
# On macOS, extract Claude OAuth credentials from the system keychain
# and prepare a minimal .claude.json for the container.
AUTH_TMPDIR=$(mktemp -d)
trap 'rm -rf "$AUTH_TMPDIR"' EXIT
AUTH_MOUNT_ARGS=()
if [[ "$OSTYPE" == darwin* ]]; then
KEYCHAIN_CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USER" -w 2>/dev/null || true)
if [[ -n "$KEYCHAIN_CREDS" ]]; then
mkdir -p "$AUTH_TMPDIR/.claude"
echo "$KEYCHAIN_CREDS" > "$AUTH_TMPDIR/.claude/.credentials.json"
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/.claude/.credentials.json:/home/claude/.claude/.credentials.json")
fi
fi
# Build a minimal .claude.json with onboarding complete and project trust.
# Mounting the host's full ~/.claude.json won't work (host-specific paths).
python3 -c "
import json, os
host_config = '$HOME/.claude.json'
config = {}
if os.path.exists(host_config):
with open(host_config) as f:
full = json.load(f)
# Copy only the auth-related keys
for key in ('oauthAccount', 'customApiKeyResponses'):
if key in full:
config[key] = full[key]
config['hasCompletedOnboarding'] = True
config['bypassPermissionsModeAccepted'] = True
config['dangerouslySkipPermissions'] = True
config['autoAcceptPermissions'] = True
config['numStartups'] = 1
config['projects'] = {
'/app': {
'allowedTools': [],
'hasTrustDialogAccepted': True,
'skipConfirmationDialogs': True,
}
}
with open('$AUTH_TMPDIR/claude.json', 'w') as f:
json.dump(config, f, indent=2)
"
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/claude.json:/home/claude/.claude.json")
# Parse mise.local.toml for env vars (DB connection, ports, etc.)
ENV_ARGS=()
if [[ -f "$PROJECT_ROOT/mise.local.toml" ]]; then
while IFS='=' read -r key value; do
key="$(echo "$key" | xargs)"
value="$(echo "$value" | sed 's/^[[:space:]]*"//;s/"[[:space:]]*$//')"
if [[ -n "$key" && -n "$value" ]]; then
# Rewrite localhost to host.docker.internal so container can reach host DBs
if [[ "$key" == "MB_DB_CONNECTION_URI" ]]; then
value="${value//localhost/host.docker.internal}"
fi
ENV_ARGS+=(-e "$key=$value")
fi
done < <(grep -E '^[A-Z_]+ *= *"' "$PROJECT_ROOT/mise.local.toml")
fi
exec docker run -it --rm \
-v "$PROJECT_ROOT:/app" \
-w /app \
-v /dev/null:/app/mise.local.toml:ro \
"${AUTH_MOUNT_ARGS[@]}" \
-e ANTHROPIC_API_KEY \
"${ENV_ARGS[@]}" \
--add-host=host.docker.internal:host-gateway \
"$IMAGE_NAME" \
--dangerously-skip-permissions

View File

@@ -0,0 +1,38 @@
FROM ubuntu:noble
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git rlwrap bash ca-certificates gnupg ripgrep sudo \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user (--dangerously-skip-permissions refuses to run as root)
RUN useradd -m -s /bin/bash claude
RUN echo 'claude ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
USER claude
# Install mise
RUN curl https://mise.run | sh
ENV PATH="/home/claude/.local/bin:$PATH"
# Copy project mise.toml to install the same tool versions
COPY --chown=claude:claude mise.toml /home/claude/.config/mise/config.toml
# Trust the mise config files so there's no interactive prompt
RUN mise trust /home/claude/.config/mise/config.toml
# Install all tools defined in mise.toml
RUN mise install
# Put mise shims on PATH
RUN echo 'eval "$(mise activate bash)"' >> /home/claude/.bashrc
ENV PATH="/home/claude/.local/share/mise/shims:$PATH"
# Auto-accept mise trust prompts for project configs mounted at runtime
ENV MISE_YES=1
# Create .claude directory so Claude Code can write session data
RUN mkdir -p /home/claude/.claude
WORKDIR /app
ENTRYPOINT ["claude"]

View File

@@ -65,7 +65,9 @@
(def cli-spec [["-h" "--help" "Show this help text"]
["-H" "--hot" "Enable hot reloading"] ;
["-p" "--port PORT" "Port to run the nREPL server on"
:default 50605
:default (if-let [p (env/env :nrepl-port)]
(Integer/parseInt p)
50605)
:parse-fn #(Integer/parseInt %)]])
(defn -main

View File

@@ -0,0 +1,328 @@
(ns mage.edpaget.dev-env
(:require
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u]
[table.core :as t]))
(set! *warn-on-reflection* true)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Port bases
(def ^:private port-bases
{:jetty 3000
:frontend-dev 8080
:nrepl 50605
:socket-repl 50505
:postgres-app 15432
:postgres-wh 25432
:mysql 13309
:mariadb 13306
:mongo 37017
:clickhouse-http 18123
:clickhouse-nat 19000
:ldap 11389
:maildev-smtp 11025
:maildev-ui 11080})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Service specs
(def ^:private service-specs
"Map of service-key -> spec. :internal-ports is a vector of [host-key internal-port] pairs."
{:postgres {:image "postgres:17"
:internal-ports [[:postgres-app 5432]]
:wh-ports [[:postgres-wh 5432]]
:env {"POSTGRES_USER" "metabase"
"POSTGRES_DB" "metabase"
"POSTGRES_PASSWORD" "password"
"PGDATA" "/var/lib/postgresql/data"}
:can-be-app-db? true}
:mysql {:image "mysql:8.4"
:internal-ports [[:mysql 3306]]
:env {"MYSQL_DATABASE" "metabase_test"
"MYSQL_ALLOW_EMPTY_PASSWORD" "yes"}
:can-be-app-db? true}
:mariadb {:image "mariadb:11"
:internal-ports [[:mariadb 3306]]
:env {"MYSQL_DATABASE" "metabase_test"
"MYSQL_ALLOW_EMPTY_PASSWORD" "yes"}
:can-be-app-db? true}
:mongo {:image "mongo:8"
:internal-ports [[:mongo 27017]]
:env {"MONGO_INITDB_ROOT_USERNAME" "metabase"
"MONGO_INITDB_ROOT_PASSWORD" "metasample123"}}
:clickhouse {:image "clickhouse/clickhouse-server:latest"
:internal-ports [[:clickhouse-http 8123] [:clickhouse-nat 9000]]
:env {"CLICKHOUSE_USER" "metabase"
"CLICKHOUSE_PASSWORD" "metasample123"}}
:ldap {:image "bitnami/openldap:latest"
:internal-ports [[:ldap 1389]]
:env {"LDAP_ADMIN_USERNAME" "admin"
"LDAP_ADMIN_PASSWORD" "adminpassword"}}
:maildev {:image "maildev/maildev"
:internal-ports [[:maildev-smtp 1025] [:maildev-ui 1080]]
:env {}}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
(defn- worktree-name
"Last path component of the project root directory."
[]
(let [path u/project-root-directory]
(last (str/split path #"/"))))
(defn- compute-slot
"Deterministic slot 0-99 from worktree name, or override."
[slot-override]
(if slot-override
slot-override
(mod (Math/abs (.hashCode ^String (worktree-name))) 100)))
(defn- container-prefix
"Prefix for all docker containers for this worktree."
[]
(str "mb-" (worktree-name) "-"))
(defn- port-for
"Compute the port for a given service key and slot."
[port-key slot]
(+ (get port-bases port-key) slot))
(defn- kill-container!
"Kill and remove a docker container (quiet, no error on missing)."
[container-name]
(shell/sh* {:quiet? true} "docker" "kill" container-name)
(shell/sh* {:quiet? true} "docker" "rm" container-name))
(defn- check-docker!
"Verify docker is available and the daemon is running."
[]
(when-not (u/can-run? "docker")
(println (c/red "Docker is not installed. Please install Docker to use dev-env."))
(u/exit 1))
(let [{:keys [exit]} (shell/sh* {:quiet? true} "docker" "info")]
(when-not (zero? exit)
(println (c/red "Docker daemon is not running. Please start Docker."))
(u/exit 1))))
(defn- build-docker-cmd
"Build a docker run command vector for a service."
[container-name image port-mappings env-map]
(into ["docker" "run" "-d"]
(concat
(mapcat (fn [[host-port internal-port]]
["-p" (str host-port ":" internal-port)])
port-mappings)
(mapcat (fn [[k v]]
["-e" (str k "=" v)])
env-map)
["--name" container-name
image])))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Core
(defn- start-service!
"Kill existing container and start a new one."
[service-key suffix slot]
(let [spec (get service-specs service-key)
container-name (str (container-prefix) (name service-key) (when suffix (str "-" suffix)))
port-key-src (if (= suffix "app")
(:internal-ports spec)
(or (:wh-ports spec) (:internal-ports spec)))
port-mappings (mapv (fn [[pk ip]] [(port-for pk slot) ip]) port-key-src)
cmd (build-docker-cmd container-name (:image spec) port-mappings (:env spec))]
(println (c/yellow "Starting " container-name "..."))
(kill-container! container-name)
(u/debug "Running: " (str/join " " cmd))
(apply shell/sh cmd)
(println (c/green " Started ") container-name)))
(defn- validate-services!
"Validate the app-db and --with values. Returns normalized config map."
[{:keys [app-db with]}]
(let [app-db-kw (keyword app-db)
with-kws (mapv keyword with)
valid-app-dbs #{:h2 :postgres :mysql :mariadb}
valid-withs #{:postgres :mysql :mariadb :mongo :clickhouse :ldap :maildev}]
(when-not (valid-app-dbs app-db-kw)
(println (c/red "Invalid --app-db: " app-db ". Must be one of: " (str/join ", " (map name valid-app-dbs))))
(u/exit 1))
(doseq [w with-kws]
(when-not (valid-withs w)
(println (c/red "Invalid --with: " (name w) ". Must be one of: " (str/join ", " (map name valid-withs))))
(u/exit 1)))
;; Warn if --with overlaps with --app-db
(when (and (not= app-db-kw :h2) (some #{app-db-kw} with-kws))
(println (c/yellow "Note: --with " app-db " is redundant when --app-db is " app-db ". Using app-db container only.")))
{:app-db app-db-kw
:with (if (= app-db-kw :h2)
with-kws
(vec (remove #{app-db-kw} with-kws)))}))
(defn- generate-mise-local!
"Generate mise.local.toml at the project root."
[slot {:keys [app-db]}]
(let [wt-name (worktree-name)
lines (cond-> [(str "# Auto-generated by ./bin/mage -edpaget-dev-env")
(str "# Worktree: " wt-name " (slot " slot ")")
(str "# Re-run `./bin/mage -edpaget-dev-env` to regenerate.")
""
"[env]"
(str "MB_JETTY_PORT = \"" (port-for :jetty slot) "\"")
(str "MB_FRONTEND_DEV_PORT = \"" (port-for :frontend-dev slot) "\"")
(str "NREPL_PORT = \"" (port-for :nrepl slot) "\"")
(str "SOCKET_REPL_PORT = \"" (port-for :socket-repl slot) "\"")]
(= app-db :postgres)
(conj (str "MB_DB_TYPE = \"postgres\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:postgresql://localhost:"
(port-for :postgres-app slot)
"/metabase?user=metabase&password=password\""))
(= app-db :mysql)
(conj (str "MB_DB_TYPE = \"mysql\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:mysql://localhost:"
(port-for :mysql slot)
"/metabase?user=root&password=\""))
(= app-db :mariadb)
(conj (str "MB_DB_TYPE = \"mysql\"")
(str "MB_DB_CONNECTION_URI = \"jdbc:mysql://localhost:"
(port-for :mariadb slot)
"/metabase?user=root&password=\"")))
content (str (str/join "\n" lines) "\n")
path (str u/project-root-directory "/mise.local.toml")]
(spit path content)
(println (c/green "Wrote " path))))
(defn- print-summary!
"Print a summary table of ports, containers, and connection info."
[slot {:keys [app-db with]}]
(let [wt-name (worktree-name)
rows (cond-> [{:service "Jetty backend" :port (port-for :jetty slot) :env-var "MB_JETTY_PORT"}
{:service "Frontend dev" :port (port-for :frontend-dev slot) :env-var "MB_FRONTEND_DEV_PORT"}
{:service "nREPL" :port (port-for :nrepl slot) :env-var "NREPL_PORT"}
{:service "Socket REPL" :port (port-for :socket-repl slot) :env-var "SOCKET_REPL_PORT"}]
(= app-db :postgres)
(conj {:service "Postgres (app-db)" :port (port-for :postgres-app slot) :env-var "MB_DB_CONNECTION_URI"})
(= app-db :mysql)
(conj {:service "MySQL (app-db)" :port (port-for :mysql slot) :env-var "MB_DB_CONNECTION_URI"})
(= app-db :mariadb)
(conj {:service "MariaDB (app-db)" :port (port-for :mariadb slot) :env-var "MB_DB_CONNECTION_URI"})
(some #{:postgres} with)
(conj {:service "Postgres (warehouse)" :port (port-for :postgres-wh slot) :env-var ""})
(some #{:mysql} with)
(conj {:service "MySQL (warehouse)" :port (port-for :mysql slot) :env-var ""})
(some #{:mariadb} with)
(conj {:service "MariaDB (warehouse)" :port (port-for :mariadb slot) :env-var ""})
(some #{:mongo} with)
(conj {:service "MongoDB" :port (port-for :mongo slot) :env-var ""})
(some #{:clickhouse} with)
(conj {:service "ClickHouse HTTP" :port (port-for :clickhouse-http slot) :env-var ""}
{:service "ClickHouse native" :port (port-for :clickhouse-nat slot) :env-var ""})
(some #{:ldap} with)
(conj {:service "OpenLDAP" :port (port-for :ldap slot) :env-var ""})
(some #{:maildev} with)
(conj {:service "Maildev SMTP" :port (port-for :maildev-smtp slot) :env-var ""}
{:service "Maildev UI" :port (port-for :maildev-ui slot) :env-var ""}))]
(println)
(println (c/bold (c/green "Dev environment for ") (c/cyan wt-name) (c/green " (slot " slot ")")))
(println)
(t/table rows :style :unicode)
(println)
(println (c/cyan "Open a new shell in this worktree for mise to pick up the env vars."))
(println (c/cyan "Tear down with: ") (c/yellow "./bin/mage -edpaget-dev-env --down"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Entry points
(defn- stand-up!
"Orchestrate: validate -> check docker -> start containers -> write toml -> print summary."
[opts]
(let [slot (compute-slot (:slot opts))
config (validate-services! opts)]
(check-docker!)
;; Start app-db container if not H2
(when (not= (:app-db config) :h2)
(start-service! (:app-db config) "app" slot))
;; Start warehouse containers
(doseq [svc (:with config)]
(start-service! svc "wh" slot))
;; Write mise.local.toml
(generate-mise-local! slot config)
;; Print summary
(print-summary! slot config)))
(defn- tear-down!
"Find all containers for this worktree, kill/rm them, delete mise.local.toml."
[]
(check-docker!)
(let [prefix (container-prefix)
{:keys [out]} (shell/sh* {:quiet? true}
"docker" "ps" "-a"
"--filter" (str "name=^" prefix)
"--format" "{{.Names}}")
containers (when (seq out)
(->> out
(remove str/blank?)
vec))]
(if (seq containers)
(do
(println (c/yellow "Stopping containers:"))
(doseq [cname containers]
(println (c/red " Killing " cname))
(kill-container! cname))
(println (c/green "All containers stopped.")))
(println (c/yellow "No containers found for prefix: " prefix)))
;; Remove mise.local.toml
(let [toml-path (str u/project-root-directory "/mise.local.toml")]
(when (.exists (java.io.File. toml-path))
(.delete (java.io.File. toml-path))
(println (c/green "Removed " toml-path))))))
(defn- print-status!
"Show running containers for this worktree."
[]
(check-docker!)
(let [prefix (container-prefix)
{:keys [out]} (shell/sh* {:quiet? true}
"docker" "ps" "-a"
"--filter" (str "name=^" prefix)
"--format" "{{.Names}}\t{{.Status}}\t{{.Ports}}")
lines (when (seq out)
(->> out (remove str/blank?) vec))]
(if (seq lines)
(let [rows (mapv (fn [line]
(let [[cname status ports] (str/split line #"\t" 3)]
{:container cname
:status (or status "")
:ports (or ports "")}))
lines)]
(println (c/bold (c/green "Containers for " (worktree-name) ":")))
(println)
(t/table rows :style :unicode))
(println (c/yellow "No containers found for " (worktree-name))))))
(defn dev-env!
"Top-level dispatcher for dev-env command."
[{:keys [options]}]
(let [{:keys [down status]} options]
(cond
down (tear-down!)
status (print-status!)
:else (stand-up! (-> options
(update :app-db #(or % "h2")))))))