Skip to content

Recognized/mxup

Repository files navigation

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:

  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).

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages