The macterm CLI

Macterm bundles a control CLI that drives the running app — projects, tabs, panes, and their zmx sessions — over a local Unix socket. It's how scripts, AI agents, and other apps orchestrate the terminal: spawn a grid of panes, run a command in each, focus one, tear it down.

Shells that Macterm spawns already have macterm on their PATH, so inside any pane it just works:

$ macterm status
Macterm 1.4.0 (pid 4242) — active project: api

$ macterm tab new --run "npm run dev"
tab:3  *  npm  1 pane

$ macterm grid 2x2 --run "tail -f log/dev.log"
$ macterm session list
macterm-api-8f327ce4a3f8  clients:1  pid:4310  attached-pane

From a shell Macterm didn't spawn, invoke it by bundle path (or symlink it onto your PATH):

/Applications/Macterm.app/Contents/Resources/bin/macterm status

Every command takes --json for a stable, scriptable payload instead of the human table, and --socket <path> to target a specific app instance. --help works at every level of the command tree.

Commands

The grammar is macterm <noun> <verb> [options]. A bare noun defaults to its list verb, so macterm project is macterm project list.

Command Description
status Liveness probe: version, pid, active project. Exits non-zero if no app is reachable.
project list All projects with refs (project:1), active/loaded markers, tab counts.
project create <path> [--name N] [--select] Add a project for a local directory. Not idempotent — each run adds a distinct project, even for a directory that already has one; check project list first if you want create-or-select. --select activates it — and, on first open, applies a matching layout file.
project select <name|uuid|index> Make a project active.
tab list [--project P] Tabs of a project (default: active project).
tab new [--project P] [--run CMD] New tab, becomes active. --run types CMD into the fresh shell.
tab select <tab> Activate a tab (tab:3, index, UUID, or exact title).
tab close <tab> [--force] Close a tab, killing its panes' sessions. Refuses with busy when a pane runs a program, unless forced.
pane list [--project P] [--tab T] Panes with refs, session names, cwd, foreground process, focus marker, and execution state (idle/running/done; live tracking requires the tab status indicator setting).
pane inspect [target] Read-only snapshot of a pane's terminal core: grid, cell/surface pixels, scrollback totals, content scale, foreground pid + argv. Needs a live surface.
pane dump [--scrollback] [target] Print a pane's terminal text — the viewport, or the full scrollback with --scrollback. Pipeline-friendly (text only).
pane split [--direction right|down|auto] [--run CMD] [target] Split a pane; the new pane inherits the source's cwd. auto picks the longer on-screen axis.
pane focus <target> Focus a pane: selects its tab, fronts the window, restores keyboard focus.
pane focus --direction left|down|up|right [target] Focus the nearest pane that way from the target — the same geometry the focus keybinds use. At the outermost edge it's a no-op, not an error.
pane close (--pane P | --session S) [--force] Close a pane, killing its session. Always explicit — never defaults to "the pane you're in".
pane run <command…> [target] Type a command (plus newline) into an existing live pane's shell.
pane key <chord> [target] Send one key chord (ctrl+c, escape, up, ctrl+\) to a live pane through the terminal's key-encoding path — control/named keys that pane run can't type.
pane zoom [target] Toggle zoom on a pane (the tab renders only that pane while zoomed) — the same action as the zoom keybind.
pane resize-split --axis horizontal|vertical --ratio R [target] Set the ratio (0.15–0.85) of the nearest split of that axis around a pane. Absolute geometry, unlike the keybind's relative nudge.
grid <RxC> [--run CMD] [target] Split a pane into an equal R×C grid (≤16 cells). --run spawns CMD in every new pane.
session list / session info <name> zmx sessions as the daemon reports them, with attached-pane mapping.
session kill <name> Kill a zmx session. If a live pane is attached, that pane's shell exits.
layout apply [--project P] [--force] Reconcile the workspace to the project's layout file. Returns busy instead of closing panes, unless forced.
layout save [--project P] Write the live workspace to ~/.config/macterm/projects/<slug>.yaml.

pane run types into a live shell — different from --run, which only applies to a pane at spawn time. Use pane key for keys with no text form: pane run pastes characters, pane key sends an encoded keypress (a control byte like ctrl+c, or a named key like escape/up), so libghostty applies the terminal's current mode (application cursor keys, Kitty protocol, …). Quote chords the shell would otherwise mangle: macterm pane key 'ctrl+\'.

Targeting a pane

Projects and tabs accept a name/title, a UUID, or the 1-based index shown in list output (bare 3 or ref-style tab:3). A duplicate name is an ambiguous error, never a silent first-match.

Pane verbs (split, focus, close, run, grid) resolve their target in this order:

  1. --session <name> — the zmx session name (macterm-<slug>-<hex12>). This is the restart-stable address: pane UUIDs regenerate every launch, session names persist verbatim.
  2. --pane <uuid|index> — a pane UUID (searched project-wide), or its index within the tab scope.
  3. MACTERM_SESSION — inside a pane, Macterm sets this to that pane's own session, so a bare macterm pane split splits the pane you're in. An explicit --tab disables this fallback.
  4. Otherwise, the focused pane of the active tab.

pane close never uses the MACTERM_SESSION fallback — destroying "whatever pane I'm in" because no target was given is a footgun, so it always demands an explicit --pane or --session.

pane focus --direction treats the resolved target as the origin, not the destination — so a bare macterm pane focus --direction left run inside a pane moves left from that pane. It reports the pane that ended up focused, and at the outermost edge it reports the origin unchanged rather than failing, so a caller can tell whether it moved by comparing the reported session to its own $MACTERM_SESSION.

Introspecting a pane

pane inspect and pane dump read libghostty's own live state — the fast path for debugging scrollback/reflow behavior without hand-instrumenting a build.

$ macterm pane inspect
session             macterm-api-8f327ce4a3f8
grid                132×65
cell px             16×40
surface px          2176×2682
scrollback          360 total, 295 offset, 65 len
alt-screen          false
content scale       2.0
foreground          79497 (nvim src/main.rs)
process exited      false
needs confirm quit  false

pane dump prints the viewport's text; --scrollback prepends the full scrollback. It's the exact text libghostty would hand a "select all", so it round-trips cell contents faithfully:

$ macterm pane dump --scrollback | wc -l
360

Both need a live surface (a never-shown pane returns no_surface — select its tab once to spawn it).

Debug-only verbs

pane resize --cols C --rows R [target] drives a single, in-place ghostty_surface_set_size on a pane, bypassing the normal SwiftUI layout path — for reproducing a specific resize/reflow transition in isolation. It exists only in debug builds: it is absent from a release CLI's --help, and a release app rejects pane.resize as an unknown command. The live layout system reasserts the pane's real geometry on the next tick, so the verb drives the transition (and any reflow side effect, visible via pane inspect's scrollback) rather than persisting a size.

Environment

The app exports into every spawned shell:

Exit codes

The CLI is pipeline-safe: stdout carries output only on success, everything else goes to stderr.

Scripts can gate on liveness:

until macterm status >/dev/null 2>&1; do sleep 0.2; done

Scripting example

Spin up a project workspace from scratch, one pane per service:

dev-up.sh
#!/bin/sh
set -e
mac=/Applications/Macterm.app/Contents/Resources/bin/macterm

# Wait for the app, then open the repo as a project.
until "$mac" status >/dev/null 2>&1; do sleep 0.2; done
"$mac" project create ~/dev/myapp --select

# A tab running the dev server, split for a test watcher.
"$mac" tab new --run "npm run dev"
"$mac" pane split --direction down --run "npm test -- --watch"

Wire protocol

The macterm binary is a thin client over a documented protocol — any same-user process can speak it directly. One request per connection to the socket at ~/Library/Application Support/Macterm/control.sock: write one newline-terminated JSON line, half-close your write end, and read one line back.

request
{"v":1,"id":"<any-string>","command":"pane.split","args":{"direction":"down","run":"btop"}}
response
{"v":1,"id":"<echoed>","ok":true,"data":{"panes":[{"id":"…","session":"macterm-api-1a2b3c4d5e6f","index":2}]}}

Failures are {"ok":false,"error":{"code":"…","message":"…","action":"…"}} with snake_case codes: starting, unknown_command, bad_request, not_found, ambiguous, busy, no_surface, internal. Commands are noun.verb; unknown fields are ignored on both sides, so the protocol can grow without breaking clients. Debuggable by hand:

echo '{"v":1,"id":"x","command":"status"}' | nc -U ~/Library/Application\ Support/Macterm/control.sock

The boundary is filesystem permissions, same-user only: the socket is mode 0600 in a 0700 directory, and the CLI refuses sockets owned by another user. There's no token auth by design — it's the same trust boundary the zmx session daemon already uses.