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:

  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
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 .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 — "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:

  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

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