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, in a project-local ./.mxup/<name>.yml, or can be passed via -f path.
Where mxup looks for a config
In order, mxup tries:
-f <path>if supplied../.mxup/<name>.yml— project-local, takes precedence over the global dir.~/.config/mxup/<name>.yml../mxup.yml— bare config in the current directory.- The sole
*.ymlin./.mxup/, if exactly one is present. - The sole
*.ymlin~/.config/mxup/, if exactly one is present.
A project-local .mxup/ directory is handy for committing per-repo dev sessions alongside the code:
mkdir .mxup
cp examples/myapp-dev.yml .mxup/dev.yml
mxup up dev # picks up ./.mxup/dev.yml
session: my-project
# Optional. Relative paths are resolved against the config file's directory
# (or, when the config lives in `.mxup/`, against the project root that
# contains it — so a checked-in `.mxup/dev.yml` can just say `root: .`).
root: ~/projects/my-app
# 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: backend # = ~/projects/my-app/backend
wait_for: localhost:5432
env:
DATABASE_URL: postgres://localhost/myapp_dev
command: ./start-server.sh
frontend:
root: frontend
command: npm run dev
shell:
root: .
Fields
| Field | Required | Description |
|---|---|---|
session |
yes | tmux session name |
setup |
no | Shell snippet prepended to every window's command |
root |
no | Base directory for relative window root values. Relative paths resolve against the config file's directory — or, if the config lives in a project-local .mxup/ directory, against .mxup/'s parent (the project root). ~ and absolute paths are kept as-is. Supports $VAR / ${VAR} env-var expansion (an unset variable is an error). |
required_env |
no | Env vars that must be filled in before mxup up will start (see Required env vars) |
windows |
yes | Ordered map of window definitions |
Per window:
| Field | Required | Description |
|---|---|---|
root |
yes | Working directory. ~ and absolute paths are kept as-is; relative paths resolve against the top-level root (or CWD if unset). Supports $VAR / ${VAR} env-var expansion (an unset variable is an error). |
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) |
commands |
no | Map of named one-off commands runnable via mxup <config> <window>:<name> (see Named commands) |
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
Live env (command-backed env vars with auto-restart)
For env vars whose value comes from a command you re-run periodically (auth
tokens, short-lived credentials), declare them under live_env. Each value
is a shell command whose stdout becomes the variable's value:
live_env:
GRAZIE_TOKEN: cat ~/.grazie-token
AWS_SESSION_TOKEN: aws configure export-credentials --format env | sed -n 's/.*AWS_SESSION_TOKEN=//p'
windows:
api:
root: api
live_env: [GRAZIE_TOKEN]
command: ./gradlew run
worker:
root: worker
live_env: [GRAZIE_TOKEN, AWS_SESSION_TOKEN]
command: ./gradlew run
The window's launcher exports each variable from its command's output
(export GRAZIE_TOKEN="$(cat ~/.grazie-token)") before running its own
command, so the value is always read fresh on (re)start. Commands run in
the config's base directory, so relative paths inside them resolve the same
way as root — regardless of an individual window's root.
When mxup up finds a live_env block in the config, it spawns a
background watcher process for the session. The watcher re-evaluates each
command on an interval; whenever a command's output changes, it restarts
every window whose live_env list references it. So echo new > ~/.grazie-token is enough to bounce api and worker with the new value
— no manual restart. A command that exits non-zero is treated as "no value
yet" and never triggers a restart on its own.
| Field | Required | Description |
|---|---|---|
live_env (top-level) |
no | Map of env var name → shell command. The command's stdout is the value; it runs in the config's base directory. |
live_env (per-window) |
no | List of names from the top-level pool that this window depends on. Unknown names are an error. |
The watcher is stopped automatically by mxup down. Its PID, log, and
last-restart state live under ~/.local/share/mxup/<session>/. mxup status shows the watcher's PID and the most recent restart it issued.
Required env vars
Some values can't live in the config because they're secret or
machine-specific (API keys, DB passwords). Declare them under required_env
and mxup up will refuse to start until they're supplied:
required_env:
DATABASE_PASSWORD: # list/name-only form
STRIPE_SECRET_KEY: Test key from the dashboard # map form: name => description
windows:
api:
root: api
command: ./gradlew run
Values are read from a dotenv-style file named after the config, sitting
right next to it — ~/.config/mxup/myapp-dev.yml → ~/.config/mxup/myapp-dev.env
(a project-local .mxup/dev.yml → .mxup/dev.env).
On mxup up, mxup checks that each required var has a non-empty value in
that file. If any are missing, it creates or extends the file with a
blank NAME= line for each (descriptions become # comments), tells you
what's missing, and aborts:
$ mxup up myapp-dev
Missing required environment variable(s):
DATABASE_PASSWORD
STRIPE_SECRET_KEY — Test key from the dashboard
Fill in the values in /Users/me/.config/mxup/myapp-dev.env, then re-run `mxup up`.
# myapp-dev.env (generated)
DATABASE_PASSWORD=
# Test key from the dashboard
STRIPE_SECRET_KEY=
Fill in the blanks and re-run. Existing values are never overwritten, so the
file is safe to keep around. Each window's launcher sources it (set -a; . file; set +a) before setup and command, so every window inherits the values.
--dry-run reports what's missing but writes nothing and doesn't abort.
Keep it out of git. The
.envfile holds secrets — add*.env(or.mxup/*.envfor project-local configs) to your.gitignore.
| Field | Required | Description |
|---|---|---|
required_env |
no | List of names ([A, B]) or a map of name => description. Gates mxup up; values come from the adjacent <config-name>.env file. |
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 |
mxup [name] <window>:<command> |
Run a window's named command in a copy of its environment, streaming output to your shell |
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).
Named commands (mxup <config> <window>:<name>)
A window can declare named one-off commands under commands. These are handy
for build/test/lint chores that share a window's working directory and
environment but shouldn't be baked into its long-running command:
windows:
air-backend:
root: backend
command: ./start-server.sh
env:
APP_ENV: dev
commands:
rebuild: ./gradlew build
test: ./gradlew test
Run one with:
mxup air-dev air-backend:rebuild # config name is optional if it resolves implicitly
mxup air-backend:test
Unlike mxup exec, a named command does not run inside the live tmux
pane. It executes in a copy of that window's environment — the same root,
setup snippet, live_env, and env — as a local subprocess, streaming its
output straight to the calling shell and exiting with the command's own return
code. Because it never touches the running pane, it works whether or not the
session is up, and won't disturb the long-running process. (The window's
wait_for readiness gate is skipped; it guards the main command, not chores.)
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