Ephemeral local environments for coding agents — any stack.
Each git worktree gets its own slot — isolated ports, isolated services, isolated data. Works whether your stack runs in Docker, on the host, or a mix. No collisions, clean teardown.
Built for coding agents running tasks in parallel.
and any agent that can run shell commands.
You're running 4 Claude Code sessions in parallel. Each agent finishes its task and wants to verify — run the test suite, spin up the app, hit the endpoints. But port 3000 is taken. Agent 2 kills agent 1's server. Agent 3 waits. The verification loop that was supposed to run in parallel is now sequential. You're paying for 4 agents and getting the throughput of one.
ecluse gives each agent its own slot: isolated ports, its own services, its own infra. All 4 agents spin up, verify, and tear down independently. The full AI verification loop — build, migrate, test, e2e — runs in parallel, without collisions, without waiting.
Create worktree → Spin up env → Do work → Verify → PR → Teardown
ecluse up feat-foo # new worktree, isolated ports, isolated services
ecluse up fix-bar # parallel session, different slot, zero collisions
ecluse down feat-foo # clean teardown, nothing left behindecluse is French for "canal lock" — each session gets its own chamber, everything is isolated, nothing leaks between them.
brew install hefgi/tap/eclusecargo install ecluseThen install the agent skill:
npx skills add hefgi/ecluse -gRequires Rust 1.85+. For container and hybrid modes, OrbStack is recommended over Docker Desktop on macOS — faster, less memory.
cd my-project
ecluse init # detects mode, writes .ecluse.toml
ecluse up feat-foo # creates worktree + slot
ecluse shell feat-foo # drops into worktree with env loaded
npm run dev # PORT already set — app binds to its own portecluse init writes a .ecluse.toml at repo root. Here's what a typical one looks like:
mode = "hybrid" # container | host | hybrid
[[services]]
name = "api"
base_port = 3000 # slot 1 → PORT=3001, slot 2 → PORT=3002
command = "npm run dev" # ecluse spawns this; each session gets its own port
[[services]]
name = "postgres"
run = "docker"
base_port = 5432 # slot 1 → ECLUSE_POSTGRES_PORT=5433, slot 2 → 5434
[[services]]
name = "redis"
run = "docker"
base_port = 6379 # slot 1 → ECLUSE_REDIS_PORT=6380, slot 2 → 6381Each ecluse up picks the next free slot, starts isolated services, and writes all ports to .env.ecluse in the worktree. Type exit (or ecluse down) to tear everything down.
ecluse init detects the right mode automatically. You confirm before anything is written.
| Mode | What ecluse up does |
Best for |
|---|---|---|
container |
Runs all services in Docker (app + data) | Fully containerized stacks, devcontainer repos |
hybrid |
Runs data services in Docker, writes env, optionally spawns app | Rails/Django/Node with a postgres+redis compose file |
host |
Writes env vars, optionally spawns native services | Pure native stacks with no Docker |
The central concept is a slot — an integer from 1 to max_slots. Every resource is derived from the slot:
- Per-service port:
base_port + slot(e.g.apiatbase_port=3000, slot 1 → 3001, slot 2 → 3002) - Compose project name:
<prefix>_<slug> - Named volumes:
<volume>_<prefix>_<slug>
Three thin mode implementations share this slot primitive. Mode is selected once at init time and stored in .ecluse.toml.
How services are started depends on mode:
container— everything runs via Docker Compose. ecluse generates a per-slot overlay and callsdocker compose up.host/hybrid— native services are spawned using your system's process manager. ecluse uses tmux if available (one detached session per slot, one window per service), falling back to nohup otherwise (background processes with logs at.ecluse/logs/<slug>/). Docker data services in hybrid mode still go through Compose. Setcommandon a[[services]]entry to opt in; services withoutcommandare not spawned.
ecluse init [--mode container|host|hybrid] [--explain] [--yes]
ecluse up <slug> [--branch <name>] [--watch] [--json] [--reuse-worktree] [--port <name>=<value>]
ecluse shell <slug>
ecluse env [<slug>]
ecluse down <slug> [--keep-volumes] [--keep-branch] [--keep-worktree]
ecluse ls [--json]
ecluse validate [--ports]
Env — get the worktree path and all env vars for a running session as JSON:
ecluse env feat-foo # full JSON: worktree_path, slot, all ECLUSE_* vars
ecluse env # auto-detects session if run from inside a worktreeSoft restart — tear down services without losing your worktree, then spin them up fresh:
ecluse down feat-foo --keep-worktree # services torn down, worktree + branch kept
ecluse up feat-foo --reuse-worktree # new slot, fresh ports, worktree reusedPort override — pin a specific service to a port for this session (useful when the auto-assigned port conflicts with something ecluse can't detect):
ecluse up feat-foo --port api=4001 --port postgres=5444.ecluse.toml lives at repo root, written by ecluse init:
mode = "hybrid"
max_slots = 8
prefix = "ecluse"
worktree_dir = ".ecluse/worktrees"
# Port collision handling (both optional)
# strict_port = false # default: search for a free port on collision
# port_search_range = 10 # how many alternatives to try (bump by max_slots each time)
# One [[services]] block per service. port = base_port + slot.
# Native services run on the host; docker services run in containers.
# The first native entry also sets the PORT alias for framework compatibility.
# Add command = "..." to have ecluse spawn the process on ecluse up.
[[services]]
name = "api"
base_port = 3000 # slot 1 → ECLUSE_API_PORT=3001 + PORT, slot 2 → 3002
command = "npm run dev" # optional — ecluse spawns this on ecluse up
# port_env = "DJANGO_PORT" # also inject the port under a custom var name
# port_env = ["DJANGO_PORT", "APP_PORT"] # or multiple aliases
[[services]]
name = "postgres"
run = "docker"
base_port = 5432 # slot 1 → ECLUSE_POSTGRES_PORT=5433, slot 2 → 5434
# Optional: lifecycle hooks — run in the worktree with all env vars set
[hooks]
on_up = "npx prisma migrate deploy"
on_down = "npx prisma migrate reset --force"ecluse init writes ~/.config/ecluse/config.toml with the detected process manager (tmux if installed, otherwise nohup). Services with command are spawned on ecluse up and killed on ecluse down. Set process_manager = "none" to opt out.
[[services]] for monorepos and multi-service stacks: define one block per service. Each gets a stable, collision-free port per slot (base_port + slot). Omit [[services]] entirely for single-service projects — ecluse falls back to a single PORT = 3000 + slot.
Multiple compose files in a monorepo: point each docker service at its own compose file with the compose field (path relative to repo root). Services without compose fall back to the root compose file. ecluse generates one overlay per compose file and brings them all up under the same project name.
[[services]]
name = "api"
base_port = 3000 # native — no compose needed
[[services]]
name = "postgres"
run = "docker"
base_port = 5432 # uses root docker-compose.yml (default)
[[services]]
name = "worker-queue"
run = "docker"
base_port = 6379
compose = "services/worker/docker-compose.yml" # its own compose filePort collision handling — by default ecluse searches for a free port if the nominal one is taken, trying nominal + i × max_slots to stay out of other slots' territory. Set strict_port = true to fail immediately instead. Run ecluse validate to check your config and preview the full port allocation table.
Hooks run as shell commands inside the worktree directory with all .env.ecluse variables pre-loaded. Use them for migrations, seeding, or teardown. ecluse doesn't manage databases directly — your app's own tooling handles that via on_up.
Ports are checked, not reserved. ecluse finds a free port at ecluse up time and writes it to .env.ecluse. There is a small window between the check and when your process actually binds — if something else takes the port in between, the port in .env.ecluse will be wrong. The fix is to tear down and recreate the session:
ecluse down feat-foo --keep-worktree
ecluse up feat-foo --reuse-worktreeOr pin a specific port manually:
ecluse up feat-foo --port api=4001Process management is spawn-and-kill only. For host and hybrid modes, services with command are spawned on up and killed on down. ecluse does not monitor or restart crashed processes — ecluse ls warns if a nohup-managed process has died. For a fresh start, use ecluse down feat-foo --keep-worktree && ecluse up feat-foo --reuse-worktree.
command only works if the app reads its port from the environment. ecluse injects the full .env.ecluse contents (all ECLUSE_* vars, PORT, port_env aliases) directly into the spawned process environment — no separate sourcing needed. It cannot help if:
- The port is hardcoded in source code — the app must be changed to read
$PORT. - The port is set in a config file (e.g.
config/puma.rb,vite.config.ts,.env) — ecluse does not modify app config files; update the config to read from the environment instead.
If the app reads a custom env var, use port_env to inject it under that name:
port_env = "DJANGO_PORT" # single alias
port_env = ["DJANGO_PORT", "APP_PORT"] # multiple aliasesIf the framework accepts a CLI flag, pass the var through the command:
command = "next dev --port $PORT"
command = "bundle exec rails s -p $PORT"Issues and PRs are welcome. Check the open issues for ideas — good first issues are tagged. If you're adding a new isolation mode or provider, open an issue first to discuss the approach.
Apache 2.0. See LICENSE.