unterm-cli reference
Every subcommand and flag of the unterm-cli binary, with cron / CI / pipeline examples. Pipe --json through anywhere downstream that wants raw JSON-RPC.
2026-05-03T00:00:00.000Z
Connection model
unterm-cli is mostly a thin JSON-RPC client. MCP-backed subcommands open a TCP connection to the running Unterm GUI’s MCP server, complete an auth.login handshake, and forward the call. A few user-owned settings/discovery commands (theme, lang, reference, and settings open) use local config files, compiled-in reference tables, or the HTTP settings server instead.
When the GUI starts it writes ~/.unterm/instances/<name>.json, updates ~/.unterm/active.json, and mirrors the active endpoint to ~/.unterm/server.json for older scripts. The compatibility file has three core fields:
{
"auth_token": "<uuid>",
"mcp_port": 19876,
"http_port": 19877
}
By default the CLI resolves the live active instance first, then the newest live instance under ~/.unterm/instances/, then falls back to server.json, and finally to the legacy ~/.unterm/auth_token + port 19876 for old builds. You can pin a command to one window with --instance <id> or UNTERM_INSTANCE=<id>.
The token is per-launch — it rotates whenever the GUI restarts, and the files are written 0600 so other users on the host cannot read them.
A few consequences worth knowing before you wire scripts:
- MCP-backed commands need the GUI. No GUI means no live MCP server, so commands like
session,workspace,instance, andscreenshotwill reportunterm GUI is not running — open Unterm.app to start the MCP server, or run 'unterm start' first. Settings-style commands can still read or write their local fallback files. - Everything is local. Both servers bind
127.0.0.1only. Nothing on the LAN can reach them. There is no telemetry; the CLI never phones home. - The MCP action surface is mirrored in the CLI. If a method exists on MCP, it’s either reachable from the CLI today or trivially exposable. User settings intentionally stay on the settings/config path, not the agent action path.
- Multi-instance is first-class. Use
unterm-cli --instance alpha session listto target a specific window, or omit--instanceto follow active/latest. This keeps agent scripts deterministic when several Unterm windows are open.
The wire format is line-delimited JSON-RPC 2.0 — one request per line, one response per line. If you ever need to bypass the CLI and talk to MCP directly (Python, Node, curl-with-netcat, whatever), the protocol is documented in wezterm-gui/src/mcp/server.rs.
Global flags
These are accepted on every subcommand because they’re declared global = true on the top-level clap parser:
| Flag | Purpose |
|---|---|
--json | Print the raw JSON-RPC result payload instead of the human-formatted table. Recognised by proxy, theme, session, sessions, workspace, instance, screenshot, reference, and lang. Ignored by settings open (that command never round-trips through MCP). |
--lang <code> | Force the CLI’s interface locale for this single invocation. Does not write to ~/.unterm/lang.json. Useful in scripts that need stable English output regardless of how the user has configured the GUI. Codes: en-US, zh-CN, zh-TW, ja-JP, ko-KR, de-DE, fr-FR, it-IT, hi-IN. |
--instance <id> | Route MCP-backed commands to a specific live Unterm instance, for example alpha or bravo. Equivalent to setting UNTERM_INSTANCE=<id> for the invocation. |
-h, --help | Print help for the current subcommand level. |
-V, --version | Print the binary version (matches the GUI build, e.g. unterm-cli 20260503-201120-8ceb3f23). |
There are also a handful of inherited flags from the base wezterm CLI (--skip-config, --config-file, --config name=value) — these only matter for the GUI-launching subcommands (start, ssh, connect) and are no-ops for the MCP commands documented here.
The --json flag is the one to remember. Everything below has --json examples next to the human ones because that is how you should be driving the CLI from a script.
proxy
Read or change the system-wide proxy state. The shape mirrors the GUI’s Settings → Proxy panel: there’s a global on/off, a mode (auto/manual/off), an HTTP and a SOCKS endpoint, an optional list of named “nodes” you can switch between, and a no_proxy exclusion list.
unterm-cli proxy status
unterm-cli proxy nodes
unterm-cli proxy switch <NAME>
unterm-cli proxy disable
unterm-cli proxy env
proxy status
$ unterm-cli proxy status
Proxy: ON
Mode: auto
HTTP: http://127.0.0.1:7897
SOCKS: socks5://127.0.0.1:7897
Current node: (none)
Node count: 0
No-proxy: 127.0.0.1,192.168.0.0/16,...,localhost,*.local,<local>
$ unterm-cli --json proxy status
{
"current_node": null,
"enabled": true,
"health": {
"hint": "",
"reachable": true,
"source": "manual",
"url": "http://127.0.0.1:7897"
},
"http_proxy": "http://127.0.0.1:7897",
"mode": "auto",
"no_proxy": "127.0.0.1,...,<local>",
"node_count": 0,
"socks_proxy": "socks5://127.0.0.1:7897"
}
The health block is what the GUI uses to render the green/red dot in the proxy chip — reachable: false means the upstream proxy didn’t answer the last heartbeat. Take it as a hint, not a guarantee; the heartbeat is cheap and async.
proxy nodes
Lists named nodes from your proxy config. The active node, if any, is marked with *.
$ unterm-cli proxy nodes
NAME URL
* cn-shanghai http://127.0.0.1:7897
us-east-tunnel http://10.0.0.5:8118
When no nodes are configured the human formatter prints (no proxy nodes configured). The JSON form returns an empty nodes array — easier to feed to jq.
proxy switch <NAME>
Sets the current node. The argument is the node name (not the URL). The MCP server actually accepts node_name on the wire; the CLI translates the surface for you. Example:
$ unterm-cli proxy switch us-east-tunnel
Switched: true
Current node: us-east-tunnel
HTTP: http://10.0.0.5:8118
Switching also flips enabled = true. If you’ve previously disabled the proxy, switch is the easy way back on.
proxy disable
Hard off. Sets mode = off, clears enabled. Re-enabling without losing your config is what proxy switch is for, since disable doesn’t drop the node list — it just deactivates the global flag.
$ unterm-cli proxy disable
Proxy disabled.
proxy env
Emits export lines for the current proxy as POSIX shell. If the proxy is off, prints a comment instead of fake env vars.
$ unterm-cli proxy env
export ALL_PROXY=socks5://127.0.0.1:7897
export HTTPS_PROXY=http://127.0.0.1:7897
export HTTP_PROXY=http://127.0.0.1:7897
export NO_PROXY='127.0.0.1,...,<local>'
The output is shell-quoted: values that contain anything outside [A-Za-z0-9:/.,_=-] get wrapped in single quotes, with embedded ' escaped. Safe to eval.
Real-world use case — drop this in your ~/.zshrc or a project direnv .envrc so any new shell inherits whatever proxy Unterm is currently using:
# ~/.zshrc
if command -v unterm-cli >/dev/null 2>&1; then
eval "$(unterm-cli proxy env 2>/dev/null)"
fi
When you flip proxies in Unterm’s GUI, you don’t have to restart shells — open a new tab and the new shell picks it up. For long-lived shells that need re-sync, a simple alias alias rsp='eval "$(unterm-cli proxy env)"' is enough.
session
Operates on a single live pane. “Session” here means one terminal tab/pane in the running GUI. The CLI wraps the MCP methods session.list, session.create, session.input, screen.text, session.cwd, exec.status, screen.detect_errors, session.history, screen.search, session.suggest*, session.recording_start/stop/status, and session.export_markdown.
unterm-cli session list
unterm-cli session create [--cwd DIR] [--profile NAME] [-- COMMAND]
unterm-cli session split [--id <ID>] [--direction right|left|down|up] [--size-percent N] [--cwd DIR]
unterm-cli session focus [--id <ID>]
unterm-cli session resize [--id <ID>] --cols N --rows N
unterm-cli session destroy --id <ID>
unterm-cli session record start [--id <ID>]
unterm-cli session record stop [--id <ID>]
unterm-cli session record status [--id <ID>]
unterm-cli session export [--id <ID>] [-o FILE]
unterm-cli session input [--id <ID>] [--stdin] [--enter] <TEXT...>
unterm-cli session text [--id <ID>]
unterm-cli session cwd [--id <ID>]
unterm-cli session status [--id <ID>]
unterm-cli session errors [--id <ID>]
unterm-cli session history [--id <ID>] [--limit N]
unterm-cli session audit-log [--id <ID>] [--limit N]
unterm-cli session search [--id <ID>] [--max-results N] [--goto|--goto-match N] <PATTERN...>
unterm-cli session suggest post [--id <ID>] [--rationale TEXT] [--ttl-ms N] <TEXT...>
unterm-cli session suggest status <SUGGESTION_ID>
unterm-cli session suggest cancel <SUGGESTION_ID>
unterm-cli session suggest list [--id <ID>]
When --id is omitted on any pane-scoped subcommand, the CLI auto-resolves it to the first pane returned by session.list. Convenient if you only have one tab open; brittle if you have several. Pass --id explicitly in scripts.
session list
$ unterm-cli session list
ID COLS ROWS SHELL TITLE
0 191 77 unknown ✳ Claude Code
2 171 77 unknown ⠂ Check current project progress
$ unterm-cli --json session list
{
"sessions": [
{
"cols": 191, "rows": 77,
"cursor": { "visible": false, "x": 0, "y": 7812 },
"domain_id": 0, "id": 0, "is_dead": false,
"shell": {
"cwd": null,
"process_name": "/Users/alexlee/.local/share/claude/versions/2.1.126",
"shell_type": "unknown"
},
"title": "✳ Claude Code"
}
]
}
The IDs are stable for the lifetime of a pane and monotonically increasing. They are not reused after a pane closes, so a script that snapshots IDs once and replays them later is safe — at worst you’ll get “Session 7 not found” rather than acting on the wrong pane.
session create
Creates a new tab in the target instance. With no arguments it opens the default shell. --cwd sets the starting directory, --profile overlays an identity profile’s environment for this tab, and a trailing command runs through the platform shell.
$ unterm-cli session create --cwd /tmp -- 'printf "hello from unterm\n"; exec zsh'
Pane: 12
Title: zsh
$ unterm-cli --instance bravo --json session create --profile work -- 'gh auth status; exec zsh'
{
"command": "gh auth status; exec zsh",
"cols": 217,
"id": 12,
"profile": "work",
"rows": 60,
"session_id": "12",
"title": "zsh"
}
session split / focus / resize / destroy
Pane lifecycle helpers over MCP session.split, session.focus, session.resize, and session.destroy.
$ unterm-cli session split --id 0 --direction right --size-percent 40 --cwd /tmp
Pane: 13
Title: zsh
Direction: right
$ unterm-cli session focus --id 13
true
$ unterm-cli session resize --id 13 --cols 120 --rows 40
ok
destroy requires an explicit id so a script cannot accidentally close the first pane by omission:
$ unterm-cli session destroy --id 13
true
session record start
Begins a redacted markdown recording of a pane. Returns a UUID (session_id) and the on-disk paths the recording will land at.
$ unterm-cli session record start --id 0
Session id: 8dee59d3-0e21-4ebf-a8cf-a2c356b53b70
Log path: /Users/alexlee/.unterm/sessions/_orphan/2026-05-03/tab-0-221510.log
Markdown (on stop): /Users/alexlee/.unterm/sessions/_orphan/2026-05-03/tab-0-221510.md
If the pane has a project cwd, recordings land under <cwd>/.unterm/sessions/<date>/ instead of ~/.unterm/sessions/_orphan/<date>/. The fallback is what you get when the pane has no detectable project root.
session record stop / record status
stop finalises the markdown (no further blocks captured), prints summary stats, and is idempotent — calling it on a non-recording pane prints a benign “not recording” message rather than failing.
$ unterm-cli session record stop --id 0
Session id: 8dee59d3-0e21-4ebf-a8cf-a2c356b53b70
Block count: 0
Markdown: /Users/alexlee/.unterm/sessions/_orphan/2026-05-03/tab-0-221510.md
Exit reason: recording_stopped
status is read-only and useful for “was I already recording?” guards in scripts:
$ unterm-cli --json session record status --id 0
{ "enabled": false }
session export
Snapshots a pane’s accumulated block log to markdown without stopping recording (or even requiring recording to be active — the block buffer is always populated when OSC 133 is in play). Two flag modes:
- No
-o: MCP picks the destination, the path is printed. -o FILE: the CLI passes the path through to MCP, and additionally copies the file toFILEon the local filesystem if MCP wrote elsewhere. End state:FILEalways exists at the path you asked for.
$ unterm-cli session export --id 0 -o /tmp/snapshot.md
/tmp/snapshot.md
Real-world use case — a “git pre-push” hook that exports the last 100 blocks of your build pane and attaches them to the commit message:
# .git/hooks/pre-push
PANE_ID=$(unterm-cli --json session list | jq '.sessions[] | select(.title|test("build|ci")) | .id' | head -1)
[ -z "$PANE_ID" ] && exit 0
unterm-cli session export --id "$PANE_ID" -o ".git/last-build.md"
session input / session text
session input writes text through MCP session.input. It does not append a newline unless you pass --enter, which appends carriage return to match a real Enter keypress.
$ unterm-cli session input --id 0 --enter 'cargo test -p unterm-cli'
ok
Use --stdin when piping generated input:
$ printf 'echo from stdin' | unterm-cli session input --id 0 --stdin --enter
ok
session text reads the visible viewport through MCP screen.text:
$ unterm-cli session text --id 0
session cwd / status / errors / history / audit-log / search
These are read-only probes for scripts and outer agents.
$ unterm-cli session cwd --id 0
/Volumes/Dev/code/unterm
$ unterm-cli session status --id 0
Status: idle
Foreground: /bin/zsh
$ unterm-cli session errors --id 0
ROW PATTERN TEXT
18291 error: error: could not compile `unterm-cli`
$ unterm-cli session history --id 0 --limit 50
cargo test -p unterm-cli
test result: ok. 4 passed; 0 failed
history reads recent non-empty pane scrollback lines through MCP session.history; it is not shell history from ~/.zsh_history or equivalent.
$ unterm-cli session audit-log --limit 20
TIME METHOD PANE AGENT DETAIL
2026-06-19T09:50:00+08:00 exec.run 7 codex cargo test
audit-log reads recent mutating MCP/CLI actions from session.audit_log; pass --id to filter to one pane.
$ unterm-cli session search --id 0 --max-results 5 error:
ROW COL TEXT
18291 0 error: could not compile `unterm-cli`
search scans pane scrollback through MCP screen.search. Add --goto to move the GUI viewport to the first match, or --goto-match N to jump to a specific match index.
Use --json for the raw MCP payloads when you need stable fields such as cwd, status, foreground_process, has_errors, errors[], entries[], audit fields, or matches[].
session suggest
Queues non-PTY suggestions through MCP session.suggest. The CLI does not type the text into the shell; the user accepts or dismisses it in the terminal UI.
$ unterm-cli session suggest post --id 0 --rationale "next diagnostic step" -- cargo test -p unterm-cli
Suggestion: sg_1781805012345_1
Status: queued
$ unterm-cli session suggest list --id 0
SUGGESTION PANE AGENT TEXT
sg_1781805012345_1 0 codex cargo test -p unterm-cli
Use status to inspect a suggestion payload and cancel to withdraw a pending suggestion.
exec
Run commands in a live pane through the MCP exec.* methods. This is the command-oriented layer above session input.
unterm-cli exec run [--id <ID>] -- <COMMAND...>
unterm-cli exec wait [--id <ID>] [--timeout-ms N] -- <COMMAND...>
unterm-cli exec status [--id <ID>]
unterm-cli exec cancel [--id <ID>]
unterm-cli exec signal [--id <ID>] SIGINT|SIGTSTP|SIGQUIT|EOF
run sends the command and returns immediately:
$ unterm-cli exec run --id 0 -- cargo test -p unterm-cli
true
wait wraps the command with Unterm’s shell-specific sentinel and prints the captured output when the sentinel appears:
$ unterm-cli exec wait --id 0 --timeout-ms 60000 -- cargo test -p unterm-cli
status and cancel are pane-scoped probes/actions:
$ unterm-cli exec status --id 0
Status: running
Foreground: cargo
$ unterm-cli exec cancel --id 0
true
signal exposes MCP signal.send directly for the other terminal control characters:
$ unterm-cli exec signal --id 0 SIGTSTP
true
Supported values are SIGINT/INT, SIGTSTP/TSTP, SIGQUIT/QUIT, and EOF. These are written as control characters to the PTY; they are not POSIX kill(2) signals.
Use --json for the raw result fields: { sent }, { output, exit_status, timed_out, marker }, { status, foreground_process }, { cancelled }, or { sent, signal }.
sessions
Browse the persistent recording archive on disk (the markdown files written by session record stop and friends). The MCP-side methods are session.recording_list and session.recording_read.
unterm-cli sessions list [--project <SLUG>]
unterm-cli sessions read <SESSION_ID>
sessions list
$ unterm-cli sessions list
SESSION_ID BLOCKS STARTED PROJECT
95397675-cb95-4116-9c8e-64a0c32ce927 1 2026-04-30T22:03:26.889127+08:00 alexlee
0ec12e9d-a9ae-43f6-a654-4b484789727e 1 2026-05-01T09:40:29.205705+08:00 unterm
8ae4a612-08e2-4ba5-af1f-d815c80abdd2 1 2026-05-01T09:54:38.014989+08:00 unterm
Filter to a project:
$ unterm-cli sessions list --project unterm
The “project slug” is the basename of the directory the recording originated from (or _orphan for recordings without a detectable project). It’s matched as an exact string, not a glob.
JSON form returns an array (not an object with a sessions key — note the asymmetry vs session.list):
$ unterm-cli --json sessions list | jq '.[0]'
{
"block_count": 1,
"bytes_raw": 4136,
"ended_at": "2026-04-30T14:03:29.296032+00:00",
"log_path": "/Users/alexlee/.unterm/sessions/alexlee/2026-04-30/tab-0-220326.log",
"md_path": "/Users/alexlee/.unterm/sessions/alexlee/2026-04-30/tab-0-220326.md",
"project_path": "/Users/alexlee/",
"project_slug": "alexlee",
"started_at": "2026-04-30T22:03:26.889127+08:00",
"tab_id": 0,
"unterm_session_id": "95397675-cb95-4116-9c8e-64a0c32ce927"
}
sessions read <SESSION_ID>
Streams the recorded markdown to stdout. The argument is the UUID from sessions list, not the path. Pipe it however you like.
$ unterm-cli sessions read 95397675-cb95-4116-9c8e-64a0c32ce927 | head
---
unterm_session_id: 95397675-cb95-4116-9c8e-64a0c32ce927
tab_id: 0
project_path: /Users/alexlee/
project_slug: alexlee
shell: /bin/sh
hostname: 192.168.5.7
unterm_version: 20260502-121851-b3680e89
started_at: 2026-04-30T22:03:26.889127+08:00
ended_at: 2026-04-30T14:03:29.296032+00:00
The frontmatter is YAML; the body is fenced markdown blocks, one per OSC 133 prompt. Tokens, GitHub PATs, AWS keys, and 40+ char base64/hex strings are masked at recording time.
Real-world use case — feed a recent recording to a model for review:
LAST=$(unterm-cli --json sessions list --project unterm | jq -r '.[-1].unterm_session_id')
unterm-cli sessions read "$LAST" | claude -p "summarise what I did in this session"
workspace
Save and restore named pane workspaces. This wraps workspace.save, workspace.restore, and workspace.list on MCP.
unterm-cli workspace list
unterm-cli workspace save <NAME>
unterm-cli workspace restore <NAME> [--dry-run]
workspace save snapshots the current panes’ titles and working directories into ~/.unterm/workspaces/<NAME>.json. workspace restore opens new tabs for the saved working directories; it does not close or replace the panes you already have open.
workspace list --json includes path, saved_at, and session_count for each saved workspace, so scripts can pick the newest or largest workspace without reading the JSON files themselves.
Use --dry-run before restoring a large workspace:
$ unterm-cli --json workspace restore release-train --dry-run | jq '.planned[].cwd'
instance
List, inspect, label, or focus live Unterm windows. This is the companion to the global --instance <id> flag: first discover the instance id, then route future commands to that window.
unterm-cli instance list
unterm-cli instance info
unterm-cli instance set-title "release train"
unterm-cli instance set-title --clear
unterm-cli instance focus
instance list reads the live instance registry through MCP and omits auth tokens from the table. Use --json when a script needs to pick by cwd, title, pid, or port:
$ unterm-cli --json instance list | jq '.instances[] | {id,title,cwd,pid}'
Once you know the id, target that window explicitly:
$ unterm-cli --instance bravo session create --cwd ~/src/app -- 'cargo test; exec zsh'
agent
Install, authenticate, configure, launch, or run AI coding-agent CLIs through Unterm’s profile and MCP wiring. agent launch opens the vendor CLI interactively; agent run uses the vendor’s non-interactive mode and waits for the task to finish.
unterm-cli agent run <codex-cli|claude-code|gemini-cli|opencode> [--profile <id>] [--cwd <path>] [--stdin] [--dry-run] <prompt...>
unterm-cli agent whoami
unterm-cli agent trusted
unterm-cli agent trust <agent-name>
unterm-cli agent untrust <agent-name>
agent run currently supports:
| Agent | Headless command used by Unterm |
|---|---|
codex-cli | codex exec <prompt> |
claude-code | claude -p <prompt> |
gemini-cli | gemini -p <prompt> |
opencode | opencode run <prompt> |
The command reuses the same auth mode and settings as agent launch. If the agent is signed in with its official subscription flow in Web Settings -> AI Agents, Unterm does not inject an API key or switch it to per-token billing.
| Flag | Purpose |
|---|---|
--profile <id> | Run with a specific Unterm identity profile. |
--cwd <path> | Run from a specific project directory. |
--stdin | Read additional prompt text from stdin; useful for diffs, logs, and exported sessions. |
--dry-run | Print the command and redacted environment instead of starting the agent. |
$ unterm-cli agent run codex-cli --cwd ~/src/app "review this diff and list risky changes"
$ unterm-cli agent run claude-code --profile work "summarise the last failing test output"
$ unterm-cli agent run gemini-cli --cwd ~/src/app "summarise this repository and suggest the next useful task"
$ unterm-cli agent run opencode --profile work "inspect the current project and suggest the next useful task"
$ git diff | unterm-cli agent run codex-cli --stdin "review this diff"
For automation, combine --json with --dry-run to get a structured launch preview with agent, profile, cwd, exec, args, redacted env_set, and prompt_chars:
$ unterm-cli --json agent run claude-code --dry-run "summarise this repo"
The trust commands wrap the MCP governance methods agent.whoami, agent.list_trusted, agent.trust, and agent.untrust. Use them to inspect or change which local MCP agent names can write to panes without a confirmation banner:
$ unterm-cli agent trusted
Runtime: codex
Static config: -
AGENT WRITES
codex 42
settings
Open the Web Settings UI in your default browser. This subcommand does not hit MCP — it reads server.json directly to find the http_port and shells out to open / xdg-open / cmd /C start.
unterm-cli settings open [--section <name>] [--print-only]
| Flag | Purpose |
|---|---|
--section <name> | Open directly to a Web Settings section: general, profiles, agents, mcp, appearance, proxy, scrollback, compat, recording, project, reference, or about. |
--print-only | Print the URL and exit; don’t launch a browser. |
$ unterm-cli settings open --print-only
http://127.0.0.1:19877
$ unterm-cli settings open --section agents --print-only
http://127.0.0.1:19877#agents
$ unterm-cli settings open --section recording
http://127.0.0.1:19877#recording
# (browser tab opens)
The URL also points at a static SPA at /, plus a small REST surface for the same operations as MCP — handy if you want to drive Unterm from JavaScript in a browser without speaking JSON-RPC.
screenshot
Capture the screen and save the PNG. Backed by the capture.* MCP methods.
unterm-cli screenshot [--include-window] [--base64] [-o FILE]
unterm-cli screenshot --scrollback [--pane N] [--max-rows N] [--dpi N] [-o FILE]
unterm-cli screenshot --scroll-app APP [--scroll-title TEXT] [--scroll-pid PID] [--max-frames N] [-o FILE]
| Flag | Purpose |
|---|---|
--include-window | Include Unterm’s own window in the capture. Default: pass include_window=false to MCP, which the GUI honours best-effort (screencapture cannot literally exclude one window, so on macOS this currently maps to “capture full screen anyway” — treat the flag as a hint). |
--self | Capture Unterm’s own window instead of the whole screen. |
--base64 | Include image.base64 in --json output for normal screen capture and --self. Long screenshot modes still return file paths. |
--scrollback | Render the active pane’s entire scrollback plus viewport into one tall PNG. This is headless re-rendering, not pixel stitching, so it works even when the window is occluded. |
--pane <N> | Pane id for --scrollback; defaults to the active pane. |
--max-rows <N> | Row cap for --scrollback; keeps the most recent rows. |
--dpi <N> | Raster DPI for --scrollback, clamped to 48-288. |
--scroll-app <APP> | macOS external long screenshot: find another app’s window by app-name substring, scroll it, and stitch the frames. |
--scroll-title <TEXT> | macOS external long screenshot: match the target window by title substring. |
--scroll-pid <PID> | macOS external long screenshot: match the target window by owning process id. |
--max-frames <N> | Frame cap for external scroll stitching. |
-o, --output <FILE> | Local path to write the PNG to. The CLI copies from the MCP-side path if MCP writes elsewhere. End state: FILE exists. |
$ unterm-cli screenshot --output /tmp/cap.png
/tmp/cap.png
$ unterm-cli screenshot --scrollback --max-rows 20000 --output /tmp/pane-long.png
/tmp/pane-long.png
$ unterm-cli screenshot --scroll-app Safari --scroll-title "Release notes" --max-frames 40 --output /tmp/safari-long.png
/tmp/safari-long.png
$ unterm-cli --json screenshot | jq '.image'
{
"path": "/Users/alexlee/.unterm/screenshots/screen_20260503_221502_301.png",
"width": 2560,
"height": 1440,
"left": 0,
"top": 0
}
The --json form additionally returns captures[] with the on-screen text content of every visible Unterm pane — handy if you want to capture both the pixels and the textual state in one round trip. Add --base64 when you need the PNG inline rather than only as a path.
Real-world use case — a CI step that snaps the screen of a self-hosted runner whenever the build fails, for human triage:
# .github/scripts/on-failure.sh
unterm-cli screenshot --include-window -o "/tmp/ci-failure-${GITHUB_RUN_ID}.png"
gh run upload-artifact "/tmp/ci-failure-${GITHUB_RUN_ID}.png" --name screenshot
scrollback
Dump a pane’s scrollback plus visible viewport as text. This is the text-first companion to screenshot --scrollback when the next consumer is an LLM or script.
unterm-cli scrollback [--pane-id ID] [--tail N] [--start-line N] [--end-line N] [--escapes] [-o FILE]
Use --tail for the common “give me the recent terminal output” case:
$ unterm-cli scrollback --tail 200 -o /tmp/recent-terminal.txt
/tmp/recent-terminal.txt
--json returns the raw screen.scrollback_text payload, including first_row, row_count, scrollback_top, and viewport metadata.
upload
Upload a local file through the running Unterm GUI’s MCP server. Backed by upload.file; credentials stay in ~/.unterm/upload.json, and the MCP response only returns the public URL, provider, key, and size.
unterm-cli upload setup [--force]
unterm-cli upload config-path
unterm-cli upload <FILE> [--provider oss|cos|qiniu] [--key OBJECT_KEY] [--raw]
Run setup once to create a starter config:
$ unterm-cli upload setup
/Users/alexlee/.unterm/upload.json
Edit that file with your OSS, COS, or Qiniu credentials. setup refuses to overwrite an existing file unless you pass --force.
$ unterm-cli upload /tmp/pane-long.png --raw
https://cdn.example.com/unterm/pane-long_20260619_001122.png
$ unterm-cli --json upload /tmp/pane-long.png | jq '{url, provider, key, size}'
reference
Print the MCP methods, CLI subcommands, and live keybindings exposed by Unterm. When the GUI is running, this calls meta.surface for MCP methods and live keybindings, while CLI subcommands come from the current unterm-cli binary so local help never lags behind an older running GUI. When the GUI is not reachable, it falls back to the compiled-in MCP/CLI reference tables and returns an empty keybindings list.
unterm-cli reference [--section mcp|cli|keys] [--filter TEXT]
$ unterm-cli reference --section mcp --filter capture.scrollback
# excerpt
capture.scrollback Scrolling screenshot of a pane: render the ENTIRE scrollback to one tall PNG (headless re-render, works even occluded). Prefer screen.scrollback_text when only the text matters.
$ unterm-cli --json reference --section cli --filter agent | jq '.cli_commands[].name'
"agent"
If the JSON output contains "source": "static_fallback", the GUI was not reachable. The MCP and CLI tables are still useful for onboarding or scripts, but live keybindings require a running GUI.
server
Inspect the running MCP server. These commands are useful as preflight checks before an outer agent starts driving panes.
unterm-cli server info
unterm-cli server health
unterm-cli server capabilities
unterm-cli server selftest [--session-id <ID>]
$ unterm-cli server health
Status: ok
Panes: 3
Term: xterm-256color
$ unterm-cli server selftest
true
CHECK OK
mux.available true
server.health true
Use --json for the full server.health, namespace capability map, or self-test check details.
policy
Probe the current MCP write policy without changing it.
unterm-cli policy check -- <COMMAND...>
$ unterm-cli policy check -- rm -rf build
true
Reason: Policy disabled
Use --json for { allowed, reason }.
theme
List preset themes or switch to one. When Unterm is running, the CLI uses the local HTTP settings API so existing windows repaint immediately. If no GUI is available, it writes ~/.unterm/theme.json for the next launch.
unterm-cli theme list
unterm-cli theme switch <NAME> # alias: theme set
The six built-in presets are standard, midnight, daylight, classic, notion-dark, and notion-light.
$ unterm-cli theme list
Active: classic
ID NAME COLOR SCHEME DESCRIPTION
standard Standard Unterm Dark Neutral high-contrast terminal style
midnight Midnight Unterm Midnight Low-glare blue-black workspace
daylight Daylight Unterm Daylight Readable light mode for bright rooms
* classic Classic Classic Dark Plain high-contrast terminal colors
notion-dark Notion Dark Notion Dark Notion-inspired warm dark
notion-light Notion Light Notion Light Notion-inspired clean light
$ unterm-cli --json theme switch daylight
{
"color_scheme": "Unterm Daylight",
"id": "daylight",
"name": "Daylight",
"applied_live": true,
"switched": true
}
Names are matched case-insensitively. Unknown names error out with a non-zero exit and a useful message (“Unknown theme ‘foo’. Run …“).
lang
List, set, or print the active interface locale. Operates on ~/.unterm/lang.json — also no MCP round-trip. Affects only the locale the CLI itself uses for human-formatted output and (after the GUI re-reads the file) the GUI’s UI strings.
unterm-cli lang list
unterm-cli lang set <CODE>
unterm-cli lang current
Supported codes: en-US, zh-CN, zh-TW, ja-JP, ko-KR, de-DE, fr-FR, it-IT, hi-IN.
$ unterm-cli lang list
CODE ACTIVE NAME
* en-US * English
zh-CN 简体中文
zh-TW 繁體中文
ja-JP 日本語
ko-KR 한국어
de-DE Deutsch
fr-FR Français
it-IT Italiano
hi-IN हिन्दी
$ unterm-cli --json lang current
{
"code": "en-US",
"name": "English"
}
lang set persists; the global --lang <code> flag does not. If you’re scripting English-only output for downstream tools, prefer --lang en-US per-invocation rather than overwriting the user’s preference.
shell-completion
Emits a completion script for your shell. Sourceable.
unterm-cli shell-completion --shell <bash|zsh|fish|elvish|powershell|fig>
$ unterm-cli shell-completion --shell zsh > "${fpath[1]}/_unterm-cli"
$ exec zsh
This is the same generator the upstream wezterm binary uses (clap_complete), which means the completions cover everything in unterm-cli --help, including the MCP subcommands documented above.
Scripting cookbook
A few patterns that come up often. They all assume unterm-cli is on PATH (the installer drops it next to the GUI binary; brew install --cask unterm symlinks it).
Wait for a long-running build, then notify
#!/usr/bin/env bash
# Block until pane $PANE_ID has been idle for 10s, then send a notification.
PANE_ID="${1:?usage: wait-then-notify <pane-id>}"
last_y=""
idle_start=""
while :; do
cur_y=$(unterm-cli --json session list \
| jq -r --argjson id "$PANE_ID" '.sessions[] | select(.id == $id) | .cursor.y')
now=$(date +%s)
if [ "$cur_y" = "$last_y" ]; then
[ -z "$idle_start" ] && idle_start=$now
if [ $((now - idle_start)) -ge 10 ]; then break; fi
else
idle_start=""
last_y=$cur_y
fi
sleep 2
done
osascript -e 'display notification "build finished" with title "unterm"'
The cursor y value monotonically increases as a pane scrolls, so freezing it for N seconds is a cheap “is idle” proxy. For a stricter signal, pair this with session export and check the tail for a known terminator string.
Capture a screenshot from a CI runner for the failure report
# scripts/on-test-failure.sh
mkdir -p artifacts
unterm-cli screenshot --include-window -o "artifacts/failure-$(date +%s).png"
unterm-cli --json session list \
| jq '.sessions[] | {id, title, lines: .cursor.y}' \
> artifacts/pane-state.json
Self-hosted runners that boot Unterm at startup get a full visual + textual snapshot every time a check fails. Both files are well under GitHub’s artifact size limits.
Auto-rotate session recordings nightly
# crontab: 30 3 * * * /usr/local/bin/rotate-unterm-sessions.sh
#!/usr/bin/env bash
THRESHOLD=$(date -v-30d +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -d '30 days ago' -Iseconds)
unterm-cli --json sessions list \
| jq -r --arg t "$THRESHOLD" '.[] | select(.started_at < $t) | .md_path' \
| while read -r path; do
[ -f "$path" ] && gzip -9 "$path"
done
A cron entry at 3:30am scans the archive, gzips anything older than 30 days. The recordings index in MCP doesn’t auto-evict — it’s expected that you bring your own retention policy.
Switch theme based on time of day from cron
# crontab:
# 0 7 * * * /usr/local/bin/unterm-cli theme switch daylight >/dev/null
# 0 19 * * * /usr/local/bin/unterm-cli theme switch midnight >/dev/null
That’s it — if Unterm is running, theme switch applies through the local settings API immediately. If it is not running, the command writes theme.json and the next launch starts with that theme.
For something fancier (latitude/longitude sunrise rather than wall-clock 7am), wrap a Python astral call around the same two CLI invocations.
Drive a multi-pane lint dashboard
This pattern is the closest the CLI comes to “outer agent” territory — kick off the same command in every pane that matches a filter, then aggregate the tails.
#!/usr/bin/env bash
# lint-everything.sh — run `make lint` in every pane whose title contains "lint"
PANES=$(unterm-cli --json session list \
| jq '[.sessions[] | select(.title | test("lint"; "i")) | .id]')
# session.send_text isn't yet wrapped by the CLI — direct MCP via netcat.
PORT=$(jq -r .mcp_port ~/.unterm/server.json)
TOKEN=$(jq -r .auth_token ~/.unterm/server.json)
# Use a small helper that speaks line-delimited JSON-RPC.
send() {
python3 -c "
import json, socket, sys
s = socket.create_connection(('127.0.0.1', $PORT))
def call(m, p):
s.sendall((json.dumps({'jsonrpc':'2.0','id':1,'method':m,'params':p}) + '\n').encode())
return json.loads(s.makefile().readline())
print(call('auth.login', {'token': '$TOKEN'}))
print(call('$1', $2))
"
}
for id in $(echo "$PANES" | jq '.[]'); do
send session.send_text "{\"id\": $id, \"text\": \"make lint\\n\"}"
done
# Wait then export tails.
sleep 30
mkdir -p /tmp/lint-report
for id in $(echo "$PANES" | jq '.[]'); do
unterm-cli session export --id "$id" -o "/tmp/lint-report/pane-$id.md"
done
For simple pane IO, prefer unterm-cli session input and unterm-cli session text over hand-written JSON-RPC snippets.
Snapshot pane state into git history
# Drop me in $PROJECT/.git/hooks/post-commit
PANE=$(unterm-cli --json session list | jq -r '.sessions[0].id')
DIR=".unterm/per-commit"
mkdir -p "$DIR"
unterm-cli session export --id "$PANE" -o "$DIR/$(git rev-parse --short HEAD).md"
git add "$DIR" 2>/dev/null
Every commit records the state of your active terminal as part of the project’s .unterm/ directory. Combined with .unterm/sessions/... recordings, you end up with a per-commit log of “what was I actually doing when I made this change”. The redaction layer keeps tokens out of the markdown.
Mirror proxy state into shell sessions
Worth repeating from earlier as a one-liner — the shell-init pattern that keeps every new shell aligned with the GUI:
# zsh
eval "$(unterm-cli proxy env 2>/dev/null)"
If unterm-cli isn’t installed, the eval no-ops because there’s no output. If the GUI isn’t running, the CLI exits non-zero with no stdout, same effect. Cheap and safe to drop into init scripts.
Exit codes
unterm-cli follows the standard convention: 0 on success, 1 on any error.
The Rust source is wezterm/src/main.rs::run(), which propagates an anyhow::Error from each subcommand up to terminate_with_error(), which calls std::process::exit(1). There are no granular status codes today — every failure mode collapses to 1. Distinguish them by message on stderr:
$ unterm-cli proxy switch nonexistent-node
ERROR unterm_cli > MCP proxy.switch failed [-32603]: Proxy node 'nonexistent-node' not found; terminating
$ echo $?
1
$ unterm-cli session record start --id 99999
ERROR unterm_cli > MCP session.recording_start failed [-32603]: Session 99999 not found; terminating
$ echo $?
1
$ unterm-cli lang set bogus-locale
ERROR unterm_cli > unknown locale 'bogus-locale'. Run `unterm-cli lang list` to see options.; terminating
$ echo $?
1
The bracketed code in MCP errors (e.g. [-32603]) is the JSON-RPC error code — -32603 is the spec’s “internal error”. For known constraint failures (locale, theme name, missing pane) the message is unambiguous. Script defensively:
if ! out=$(unterm-cli proxy status 2>&1); then
case "$out" in
*"GUI is not running"*) launchctl start com.unzooai.unterm; sleep 2; ;;
*) echo "unterm-cli failed: $out" >&2; exit 1 ;;
esac
fi
If you need machine-readable failure detail, use --json and inspect the resulting error field — but note that today the CLI translates JSON-RPC errors to anyhow errors before printing, so --json only formats the success result. For raw protocol-level errors, talk to MCP directly with the netcat / Python pattern above. That’s the escape hatch when the CLI’s surface isn’t quite enough.
Source for everything described here lives at github.com/zhitongblog/unterm under wezterm/src/unterm_cli/. Open issues / PRs there if a method exists on MCP that you’d like surfaced as a first-class CLI subcommand.