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
Homebrew (recommended on macOS)
Current Homebrew versions require third-party taps to be added and trusted explicitly:
brew tap Recognized/mxup
brew trust Recognized/mxup
brew install mxup
mxup --versionHomebrew installs the required tmux and Ruby versions automatically. If
Homebrew reports that Xcode or the Command Line Tools are outdated, update
them through System Settings or Apple Developer Downloads, then retry.
RubyGems
RubyGems installation requires Ruby 3.1+ and tmux:
ruby --version
gem install mxup
mxup --versionThe Ruby bundled with older macOS versions is too old. Install a current Ruby
first (for example with brew install ruby). If installation succeeds but
mxup is not found, add the executable directory shown by gem environment
to your PATH.
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 PATHQuick 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-devConfig 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.ymlsession: 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 |
mxup_version |
no | mxup major version this config is written for; must match the running major (see Pinning a config-format version) |
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) |
Pinning a config-format version
Every config declares the mxup major version it was written for. mxup runs it only when its own major matches; minor and patch are free on both sides.
mxup_version: '1'
session: my-projectThat works with mxup 1.0.0, 1.2.5 and 1.30.0 alike, and is refused by
0.x and 2.x. Only the major is read, so '1', '1.0' and '1.4.2' all
pin the same thing — write whichever reads best. Majors compare
numerically, so 10.x is newer than 9.x.
This is backed by a versioning promise: mxup bumps its major version only when the config format changes in a breaking way. A minor or patch release can therefore never reject a config that a previous release accepted.
The key is effectively required from 1.0 on. A config that omits it is
read as the pre-1.0 format — major 0 — and refused by 1.x, because the
0.x → 1.x profile change would otherwise alter what your session runs
without saying so:
$ mxup up my-project
This config declares no mxup_version, so it is read as the mxup 0.x config format, but mxup 1.0.0 is running.
mxup 1.x changed the config format. Run `mxup migrations` to see
what changed and how to update this config, then set mxup_version to
'1'. To keep the config as-is, install the major it was written for:
gem install mxup -v '~> 0.0'
Every command checks this before anything else, so a mismatch explains itself instead of failing on a key it doesn't recognise. Running an mxup that's behind the config points at mxup instead:
$ mxup up my-project
This config targets the mxup 2.x config format, but mxup 1.4.2 is running.
Update mxup, then try again:
brew upgrade mxup # if installed via Homebrew
gem update mxup # if installed via RubyGems
What each major changed (mxup migrations)
mxup migrations prints the config format's history — for every major, what
changed and how to update a config written for the previous one. It needs no
config file, which is the point: it's what you run when your config won't
load.
$ mxup migrations
mxup 1.0.0 — config format changes by major version.
0.x -> 1.x
1. Profiles select windows additively instead of subtracting them
Before: Every window in `windows:` ran. A profile overrode some of them
and dropped others with `~`, so `staging: {}` meant "run
everything, unchanged".
Now: `windows:` is a catalog and nothing in it runs on its own. Each
profile lists the windows it runs, and several profiles can be
selected at once (`-p a,b`) — the live set is their union. A
window no selected profile lists stays down.
Fix: In every profile, list the windows it should run: `name: {}` to
include one as declared in the catalog, or a non-empty block to
include it with overrides. …
One caveat this can't solve retroactively: mxup versions released before
mxup_version existed (0.3.1 and earlier) don't know the key, so they
report it as an unknown top-level key instead of printing the hint above.
Wait-for checks
wait_for blocks a window's command until a readiness condition is met.
Shorthand — TCP check (backward compatible):
wait_for: localhost:5432Expanded 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 messagesAll 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-projectLive 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 runThe 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 runValues 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=compactSwitch layouts on a running session without killing processes:
mxup layout my-project compactProfiles
A single project often needs to run under different stacks — "just the
backend", "frontend against staging", "everything plus a scratch shell".
Profiles are additive slices of the windows: block: each profile
lists the windows it runs, and you can select as many as you like at
once. The live selection is the union of what those profiles include.
session: my-project
windows: # a catalog; nothing here runs on its own
backend:
root: ~/projects/my-app/backend
env:
DATABASE_URL: postgres://localhost/myapp_dev
frontend:
root: ~/projects/my-app/frontend
command: npm run dev
scratch:
root: ~/projects/my-app
profiles:
backend: # backend, served locally
windows:
backend:
command: ./start-server.sh
staging-backend: # same window, pointed at staging
windows:
backend:
command: ./connect-staging.sh
env:
DATABASE_URL: postgres://staging-db/myapp
frontend:
windows:
frontend: {} # include as declared in the catalog
scratch:
windows:
scratch: {}Settings shared by every variant of a window live in the catalog; the bits that differ live in the profiles that select it.
Select profiles with --profile (short: -p), which is repeatable and
accepts a comma-separated list:
mxup up my-project -p backend,frontend # both slices
mxup up my-project -p backend -p frontend # same thing
mxup up my-project -p staging-backend -p frontend
mxup status my-project # shows the live selection| Field | Required | Description |
|---|---|---|
profiles |
no | Map of profile name → the windows it runs (plus optional overrides) |
default_profile |
no | Profile name, or list of names, selected when --profile is omitted (defaults to the first declared) |
Inclusion: a window runs when at least one selected profile lists it.
A window no selected profile lists stays down — including when it is
declared in the base windows: block. A profile that lists no windows at
all is an error, since it would bring up an empty session.
Layout groups are pruned to the windows that actually run: names are
stripped from panes: lists, and a group that ends up empty is removed
from its layout.
Overrides: {} includes a window as declared in the catalog. A
non-empty block overrides it per key, so you can tweak just command or
env without redeclaring root; env and commands maps are merged
entry by entry. A profile may also declare a window that isn't in the base
catalog at all. setup, root and layouts replace their base value; a
profile may not override session, since all profiles of a config share
one tmux session.
Vetoing: mapping a window to ~ (YAML null) is an explicit "this must
not run". On its own it changes nothing — an unlisted window is already
down — but it collides loudly if another selected profile includes the
same window:
profiles:
no-db:
windows:
backend: {}
db: ~ # refuse to run alongside anything that starts dbCollisions
Overlap between selected profiles is fine as long as they agree. Two profiles including the same window, or setting the same key to the same value, merge silently. A genuine disagreement aborts before anything starts, naming the setting and both profiles:
$ mxup up my-project -p backend,staging-backend
Profile collision: the selected profiles (backend, staging-backend) disagree.
window 'backend' → command
backend sets "./start-server.sh"
staging-backend sets "./connect-staging.sh"
Select fewer profiles, or make the conflicting values agree.
| Situation | Result |
|---|---|
| Several profiles include the same window | fine — included once |
| One includes a window, another includes it with overrides | fine |
| Two profiles set different keys of one window | fine — merged |
| Two profiles set the same key to the same value | fine |
| Two profiles set the same key to different values | collision |
| One profile vetoes a window another includes | collision |
| Several profiles veto the same window | fine — it stays down |
The same rule covers setup, root and layouts (compared whole) and
live_env (compared per variable). Every conflict in a selection is
reported at once, not one per run.
Switching: if the tmux session is already running under a different
selection, mxup up runs down first (including the graceful-stop
dance), then brings the new selection up from a clean slate. Order doesn't
matter — -p a,b and -p b,a are the same live stack, so re-running
either leaves the panes alone. mxup status always shows the currently
live selection 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 |
mxup migrations |
Print what each major version changed in the config format, and how to update a config (needs no config file) |
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(s) to select; repeatable and comma-separated. Auto-teardowns a live session running under a different selection (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 -50you 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 testRun one with:
mxup air-dev air-backend:rebuild # config name is optional if it resolves implicitly
mxup air-backend:testUnlike 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
Versioning promise. The major version is bumped only when the config
format changes in a breaking way — a config that a release accepts is
accepted by every later release sharing its major. This is what makes
mxup_version a major-only check.
Additive config features (new optional keys) go in a minor release.
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