Project

mxup

0.0
The project is in a healthy, maintained state
mxup brings a live tmux session into agreement with a YAML description of windows, commands, and layouts. It creates missing windows, restarts crashed ones, removes undeclared ones, and leaves healthy windows alone. Re-running `mxup up` is always safe.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.20
~> 13.1
 Project Readme

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 --version

Homebrew 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 --version

The 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 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:

  1. -f <path> if supplied.
  2. ./.mxup/<name>.yml — project-local, takes precedence over the global dir.
  3. ~/.config/mxup/<name>.yml.
  4. ./mxup.yml — bare config in the current directory.
  5. The sole *.yml in ./.mxup/, if exactly one is present.
  6. The sole *.yml in ~/.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
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-project

That 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: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 .env file holds secrets — add *.env (or .mxup/*.env for 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 — "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 db

Collisions

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:

  1. Resolving logical names to real tmux targetsapi may actually live as pane services.1; mxup exec -t myapp-dev:api figures that out via the active layout.
  2. Waiting for the command to finish — uses tmux wait-for with a unique marker under the hood, so exec blocks until the command actually exits.
  3. Capturing output and exit status — prints the last --lines N lines 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

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:

  1. Bump Mxup::VERSION in lib/mxup/version.rb.
  2. Commit and tag: git commit -am "Release vX.Y.Z" && git tag vX.Y.Z.
  3. 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 with contents: write on 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