mxup
Declarative tmux session manager with reconciliation.
Run mxup up any time — it creates what's missing, restarts what crashed, removes what's not declared, and leaves healthy processes alone.
Install
Requires tmux and Ruby 3.1+ (stdlib only — no runtime gem dependencies).
RubyGems
gem install mxup
Homebrew
brew install Recognized/mxup/mxup
From source
git clone https://github.com/Recognized/mxup.git ~/src/mxup
ln -sf ~/src/mxup/bin/mxup ~/.local/bin/mxup # ensure ~/.local/bin is on PATH
Quick start
# Create a config
mkdir -p ~/.config/mxup
cp ~/IdeaProjects/mxup/examples/myapp-dev.yml ~/.config/mxup/
# Bring the session up (reconcile)
mxup up myapp-dev
# Check what's running
mxup status myapp-dev
# Restart specific windows
mxup restart myapp-dev:api
mxup restart myapp-dev:api,worker
# Restart all windows
mxup restart myapp-dev
# Tear everything down
mxup down myapp-dev
Config format
Configs live in ~/.config/mxup/<name>.yml or can be passed via -f path.
session: my-project
# Shell snippet run in every window before the command
setup: |
direnv allow . 2>/dev/null
eval "$(direnv export zsh 2>/dev/null)"
windows:
database:
root: ~/projects/my-app
command: docker compose up postgres redis
backend:
root: ~/projects/my-app/backend
wait_for: localhost:5432
env:
DATABASE_URL: postgres://localhost/myapp_dev
command: ./start-server.sh
frontend:
root: ~/projects/my-app/frontend
command: npm run dev
shell:
root: ~/projects/my-app
Fields
| Field | Required | Description |
|---|---|---|
session |
yes | tmux session name |
setup |
no | Shell snippet prepended to every window's command |
windows |
yes | Ordered map of window definitions |
Per window:
| Field | Required | Description |
|---|---|---|
root |
yes | Working directory (supports ~) |
command |
no | Command to run. Omit for an interactive shell. |
env |
no | Map of environment variables to export |
wait_for |
no | Readiness check to pass before running command (see below) |
Wait-for checks
wait_for blocks a window's command until a readiness condition is met.
Shorthand — TCP check (backward compatible):
wait_for: localhost:5432
Expanded form with explicit check type:
# TCP port open
wait_for:
tcp: localhost:5432
# HTTP 2xx response
wait_for:
http: http://localhost:8080/health
# File or socket exists
wait_for:
path: /tmp/app.sock
# Arbitrary script (exit 0 = ready)
wait_for:
script: pg_isready -h localhost -p 5432
label: postgres # shown in wait/ready messages
All forms support optional timeout (seconds, default: unlimited) and interval (seconds between retries, default: 2):
wait_for:
tcp: localhost:5432
timeout: 60
interval: 5
| Option | Default | Description |
|---|---|---|
timeout |
unlimited | Max seconds to wait before giving up |
interval |
2 | Seconds between retry attempts |
label |
target value | Display name in wait/ready messages |
Parameterization
Use standard shell variable expansion in commands:
command: ./run.sh --env=${APP_ENV:-development}
Then override at invocation:
APP_ENV=production mxup up my-project
Layouts
Define multiple named layouts to control how windows are grouped as tmux panes:
layouts:
full:
services:
panes: [database, backend]
split: even-horizontal
frontend:
panes: [frontend]
compact:
all:
panes: [database, backend, frontend]
split: tiled
flat: {}
Each layout is a map of group names to group definitions. Windows in a group share a single tmux window as split panes. Windows not mentioned in any group remain standalone.
| Field | Required | Description |
|---|---|---|
layouts |
no | Map of named layout definitions |
Per group:
| Field | Required | Description |
|---|---|---|
panes |
yes | List of window names to group as panes |
split |
no | tmux layout: even-horizontal, even-vertical, main-horizontal, main-vertical, tiled (default: tiled) |
The first layout is used by default. Override with --layout:
mxup up my-project --layout=compact
Switch layouts on a running session without killing processes:
mxup layout my-project compact
Profiles
A single project often needs to run under different stacks — "local
everything", "staging backend with local frontend", etc. Profiles express
those variants as a set of overrides on top of a shared base. Only one
profile of a given config may be live at a time; mxup up of a different
profile automatically tears the current one down first.
session: my-project
windows:
backend:
root: ~/projects/my-app/backend
command: ./start-server.sh
env:
DATABASE_URL: postgres://localhost/myapp_dev
frontend:
root: ~/projects/my-app/frontend
command: npm run dev
profiles:
local: {} # uses the base as-is
staging:
windows:
backend:
command: ./connect-staging.sh
env:
DATABASE_URL: postgres://staging-db/myapp
Pick a profile with --profile (short: -p):
mxup up my-project --profile=local
mxup up my-project -p staging # tears down 'local' first
mxup status my-project # shows "profile: staging" in the header
| Field | Required | Description |
|---|---|---|
profiles |
no | Map of profile name → override block |
default_profile |
no | Profile to use when --profile is omitted (defaults to the first declared) |
Override semantics: the active profile's setup, windows, and
layouts override the base. Window overrides are merged per-key (so you
can tweak just command or env without redeclaring root). env maps
are themselves merged — keys not in the profile are inherited from the
base. A profile may not override session; profiles of the same group
must share one tmux session.
Dropping windows: to exclude a base window from a profile, map it to
~ (YAML null):
profiles:
minimal:
windows:
dev-kit: ~ # don't start dev-kit under the `minimal` profile
scratch: ~
Any layout groups that reference a dropped window are automatically
pruned — entries are stripped from panes: lists, and a group that ends
up empty is removed from its layout.
Switching: if the tmux session is already running under a different
profile, mxup up for a new profile runs down first (including the
graceful-stop dance), then brings the new profile up from a clean slate.
mxup status always shows the currently live profile in its header.
Commands
| Command | Description |
|---|---|
mxup up [name] |
Reconcile session to match config (default when no subcommand) |
mxup status [name] |
Show per-window status with recent output |
mxup down [name] |
Kill the session |
mxup restart [name:]<w1,w2,...> |
Restart specific window(s) (comma-separated) |
mxup restart [name] |
Restart all windows in the session |
mxup layout [name] |
Show available layouts and which is active |
mxup layout [name] <layout> |
Switch to a different layout (preserves running processes) |
mxup target [name:]<window> |
Print the tmux target (session:window.pane) for a logical window |
mxup target [name] |
Print targets for every declared window (tab-separated) |
mxup exec -t [name:]<window> "<cmd>" |
Run <cmd> in a pane, wait for completion, print output, exit with its status |
Flags
| Flag | Description |
|---|---|
-f path |
Use a specific config file |
--dry-run |
Preview changes without applying (for up, restart, exec) |
--lines N |
Output lines to show (for status default 15, for exec default 50) |
--layout NAME |
Layout to use (for up) |
-p, --profile NAME |
Profile to use; auto-teardowns a live session running under a different profile (for up, status, restart) |
-t TARGET |
Pane target (for exec); accepts name:window, window, or window.pane |
--timeout N |
Max seconds to wait for the command (for exec; exit 124 on timeout) |
--force |
Send the command even if the pane is busy with another process (for exec) |
-q, --quiet |
Don't print captured output (for exec) |
Running one-off commands in a pane (mxup exec)
mxup exec is a shortcut for the common "send a command to a tmux pane, wait
for it to finish, and show the output" loop. It handles the three annoying
parts for you:
- Resolving logical names to real tmux targets —
apimay actually live as paneservices.1;mxup exec -t myapp-dev:apifigures that out via the active layout. - Waiting for the command to finish — uses
tmux wait-forwith a unique marker under the hood, soexecblocks until the command actually exits. - Capturing output and exit status — prints the last
--lines Nlines of the pane and exits with the command's own exit code.
So instead of the verbose recipe:
MARKER="fulltest-$(date +%s%N)"
tmux send-keys -t myapp-dev:scratch \
"./gradlew test 2>&1 | tail -n 30; echo FULLTEST_EXIT=\$?; tmux wait-for -S $MARKER" Enter \
&& tmux wait-for $MARKER
tmux capture-pane -t myapp-dev:scratch -p -S -50
you write:
mxup exec -t myapp-dev:scratch "./gradlew test 2>&1 | tail -n 30"
echo "exit: $?"
The user command is wrapped in a subshell, so exit, set -e, or a failing
command won't kill the pane's interactive shell. By default exec refuses to
send to a pane that's currently running a non-shell process — pass --force
to override. Use --timeout N to avoid hanging indefinitely on a runaway
command (exits 124 on timeout).
Reconciliation
mxup up compares the declared config against the running tmux session:
- Missing windows → created and command started
- Extra windows → killed (with warning)
- Idle/crashed windows (shell prompt visible) → command re-sent
- Healthy running windows → left untouched
- Layout changed → panes rearranged without killing processes
Releasing
Releases are automated by .github/workflows/release.yml. To cut a new version:
- Bump
Mxup::VERSIONinlib/mxup/version.rb. - Commit and tag:
git commit -am "Release vX.Y.Z" && git tag vX.Y.Z. git push origin main --tags.
The workflow then runs the test suite, verifies the tag matches
Mxup::VERSION, publishes the gem to RubyGems via trusted publishing
(OIDC — no API keys stored), creates a GitHub release with the built .gem
attached, and — if a Homebrew tap is configured — opens a PR in the tap repo
bumping the formula.
One-time setup
RubyGems trusted publishing. Claim mxup on rubygems.org, then under
Settings → Trusted publishers add:
- Repository:
Recognized/mxup - Workflow:
release.yml - Environment: (leave blank)
Homebrew tap (optional). Create a Recognized/homebrew-mxup repo,
copy packaging/homebrew/mxup.rb to Formula/mxup.rb in it, then in the
mxup repo's settings add:
- Variable
HOMEBREW_TAP=Recognized/homebrew-mxup - Secret
HOMEBREW_TAP_TOKEN= a PAT withcontents: writeon the tap repo
The homebrew job in release.yml will then run automatically on every
tag push and keep the formula in sync.
License
MIT