Extending Roost¶
Roost is built so that everything routes through one operation set —
the same ops the UI buttons, hotkeys, and roostctl drive. That makes the
app scriptable from the outside without any plugin runtime: if you can
open a Unix socket (or run roostctl), you can drive Roost.
This guide is for users who want to automate or extend Roost — wiring up custom commands, dynamic menus, and scripts. There are three layers, from simplest to most powerful:
roostctl/ the IPC socket — script anything the app can do.- The command launcher (
command =) — pin fixed commands to a picker (⌘⇧T / Alt+Shift+T). - Dynamic providers (
provider =) — a script that generates a menu on demand and acts on your choice (⌘⇧E / Alt+Shift+E).
All three are local and trusted — the same trust level as your shell rc. Roost does not run anything from an untrusted source.
1. Drive Roost from a script¶
A running Roost serves a newline-delimited JSON socket. roostctl is a
thin CLI over it; anything roostctl does, your script can do directly.
# Find-or-create a project named "review", open a tab in it, run a command.
name="review"
roostctl open --project "$name" --cwd "$PWD" --title "$name" -- bash -lc 'make test'
open is project.ensure (find-or-create by exact name) followed by
tab.open, atomic per call — a hand-written list-then-create (tab
list to look for the project, project create if it's missing) races
another caller doing the same thing at the same time, and two of them
can each create their own "review" project (#221). open refuses
unsupported instead of racing on a Roost with no project.ensure (the
Swift Mac app today) — see open for the
full contract, including --hold/--focus and its exit codes.
A script launched inside a Roost tab can call back without any discovery
— Roost injects ROOST_SOCKET and ROOST_TAB_ID into every tab's
environment, and roostctl reads both:
See the CLI reference and the
IPC wire format for the full op set. The
Automation guide covers the fuller picture for a
script or an agent driving Roost this way — the model, the target
policy, wait/events, and the two-connection recipe for a raw-socket
client that wants to follow events instead of polling.
2. The command launcher¶
Add fixed commands to ~/.config/roost/config.conf; they show up in the
command launcher (⌘⇧T on macOS, Alt+Shift+T on Linux) and run in a new
tab when selected.
command = label="Lazygit" run="lazygit"
command = label="Logs" run="docker compose logs -f" hold=true
command = label="Claude" run="claude --resume" env="ANTHROPIC_LOG=debug"
| Key | Meaning |
|---|---|
label |
Row title (required). |
run |
Command, run through your login shell (required). |
title |
Tab title (defaults to label). |
hold |
true keeps the shell open after run exits, so output stays on screen. |
env |
K=V pairs exported before run. |
3. Dynamic providers¶
A provider is a script Roost runs to build a menu on demand, then
runs again when you pick a row. Where command = launches a fixed
command, a provider produces a dynamic list — "open one of my shed
services", "switch to a worktree", "resume a saved session" — and then
acts on the choice (which can drive Roost via $ROOST_SOCKET, or do
something else entirely).
Providers appear in the custom palette (⌘⇧E / Alt+Shift+E), and as a "Custom Commands…" row in the command palette (⌘⇧P) whenever at least one is configured.
Registering a provider¶
Two ways, which merge (config entries first, then the directory):
# In ~/.config/roost/config.conf — same grammar as `command =`:
provider = label="Open shed" run="~/.config/roost/providers/shed.sh" timeout=5 limit=100
# …or drop an executable in the providers directory beside the config:
~/.config/roost/providers/shed.sh # chmod +x; filename → label
For a directory entry, the label defaults to a humanized filename; add a header comment to override:
| Key (config form) | Meaning |
|---|---|
label / run |
Required, as for command =. |
title |
Sub-menu placeholder (defaults to label). |
timeout |
Seconds before Roost kills a hung run (default 5). |
limit |
Max rows a single list may contribute (default 100). |
The contract¶
Roost runs your run command twice, distinguished by an argv phase
($1) and the ROOST_PROVIDER_PHASE env var:
list— print the rows to populate the menu.activate— run after the user picks a row;ROOST_SELECTED_IDholds the chosen row'sid.
Input. Each run gets the active-tab context two ways — pick whichever is convenient:
- Env vars:
ROOST_SOCKET,ROOST_PROVIDER_PHASE,ROOST_QUERY,ROOST_ACTIVE_TAB_ID,ROOST_ACTIVE_PROJECT_ID,ROOST_ACTIVE_CWD,ROOST_ACTIVE_TITLE,ROOST_ROOSTCTL(absolute path to Roost's ownroostctlwhen it can resolve one — best-effort, so keep the"${ROOST_ROOSTCTL:-roostctl}"fallback; see below), and on activateROOST_SELECTED_ID.
On a tab whose project runs on a roost-session (the default local
backend), ROOST_ACTIVE_CWD is that tab's tracked cwd — its direct
child's cwd, read natively, or its OSC 7-tracked one — not something
read from this process, which holds no PTY for it.
- Stdin JSON:
{ "v": 1, "phase": "activate", "selected_id": "api", "query": "ap",
"active_tab": {"id": "7", "project_id": "3", "cwd": "/repo", "title": "build"},
"socket": "/Users/you/Library/Caches/Roost/roost.sock" }
Output (stdout, JSON). Both phases may print:
{ "placeholder": "Pick a service",
"items": [ {"id": "web", "title": "shed: web", "subtitle": "../shed/web"},
{"id": "api", "title": "shed: api"},
{"id": "_none", "title": "No services", "actionable": false} ] }
Each item has a required id (round-trips back as ROOST_SELECTED_ID)
and title, an optional subtitle, and an optional actionable
(default true). Set actionable: false for an empty/disabled row
(e.g. "No results"): it renders but can't be selected, and selecting it is
a no-op that leaves the palette open — Roost never calls activate for it.
A bare [ … ] array is also accepted. On activate, stdout is
optional — it's read as a menu only when it looks like one:
- print nothing (or
{}) → the palette closes (your script already did its work); - print more
items→ Roost drills into a sub-menu — the same schema aslist, so multi-step menus need no extra mechanism; - print anything else → ignored; the palette closes. So a
side-effecting command's incidental output (e.g. the new tab id
roostctl tab openprints) is fine — you don't need to redirect it. (Output that looks like JSON — trimmed, starts with{or[— but doesn't parse is still reported as an error, so a malformed sub-menu isn't silently swallowed.)
Opening tabs from activate¶
The usual thing activate does is open a tab that runs something, via
Roost's CLI. Call $ROOST_ROOSTCTL, not a bare roostctl — Roost
sets it to its own CLI so your provider works without roostctl on
PATH. Where that CLI lives differs by platform, which is exactly why
the env var exists:
| Platform | roostctl location |
On PATH? |
|---|---|---|
Linux (.deb) |
/usr/bin/roostctl |
✅ yes |
macOS (.dmg) |
Roost.app/Contents/Resources/bin/roostctl (inside the bundle) |
❌ no — a Finder-launched app gets a minimal PATH |
ROOST_ROOSTCTL points at the right binary on both, so the portable form
is "${ROOST_ROOSTCTL:-roostctl}" (env var first, falling back to a
PATH copy for terminal/dev use). It runs roostctl tab open, which:
… tab open --project-id "$ROOST_ACTIVE_PROJECT_ID" -- <cmd>runs<cmd>in a new tab that closes when it exits (hold=false);- add
--holdto keep the tab open afterward (drops to a shell); - add
--after-tab "$ROOST_ACTIVE_TAB_ID"to place it next to the active tab, and--focusto switch to it.
So "open this next to me and switch to it" is one call:
"${ROOST_ROOSTCTL:-roostctl}" tab open --project-id … --after-tab … --focus -- <cmd>.
See the CLI reference for the full flag set.
Safety rails. A list must print valid JSON, so don't let your
shell rc echo onto it (Roost runs run non-interactively); activate
stdout is lenient (above). Rows past limit are dropped with a "… N more"
hint; a run that exceeds timeout, exits non-zero, or prints malformed
menu JSON surfaces an error row (and is logged).
limit bounds rows, not output size — a provider stuck printing is
bounded by timeout (killed when it elapses, escalating to SIGKILL).
Discovered scripts are run by absolute (shell-quoted) path; a config
run = string is shell-interpreted, exactly like command =.
Examples¶
Because input is env-or-stdin and output is "print one JSON object", a provider is a few lines in any language:
The bash tab is a complete "Open shed" provider: list the running
sheds, and on selection open shed console <name> in a tab next to the
current one (closing when you disconnect). Drop it at
~/.config/roost/providers/shed.sh, chmod +x.
#!/usr/bin/env bash
# @roost.label: Open shed
# Roost may launch with a minimal PATH, so cover where shed + jq live:
# Homebrew (Mac), /usr/bin (Linux .deb), ~/.local/bin. Add other install
# dirs as needed. (roostctl comes from $ROOST_ROOSTCTL, below.)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:$HOME/.local/bin:$PATH"
case "${1:-}" in
list)
rows=$(shed list --json 2>/dev/null \
| jq -c '[.[] | select(.status=="running")
| {id: .name, title: ("shed: " + .name), subtitle: .ssh}]' || true)
if [ -z "$rows" ] || [ "$rows" = "[]" ]; then
printf '{"items":[{"id":"_none","title":"No running sheds","subtitle":"shed start <name>","actionable":false}]}'
else
printf '{"items":%s}' "$rows"
fi ;;
activate)
# $ROOST_ROOSTCTL is Roost's own CLI (bundled off-PATH on Mac);
# fall back to a PATH copy for terminal/dev runs. Login shell (-l)
# for PATH; the shed name is a positional arg ($1), never spliced
# into the script string. hold=false: when `shed console`
# disconnects, the shell exits → the tab closes.
"${ROOST_ROOSTCTL:-roostctl}" tab open --project-id "$ROOST_ACTIVE_PROJECT_ID" \
--after-tab "$ROOST_ACTIVE_TAB_ID" --focus --title "shed: $ROOST_SELECTED_ID" \
-- "${SHELL:-/bin/bash}" -lc 'shed console "$1"' shed "$ROOST_SELECTED_ID" ;;
esac
#!/usr/bin/env python3
import json, os, subprocess, sys
# Minimal launch PATH? Cover where shed lives: Homebrew (Mac), /usr/bin
# (Linux .deb), ~/.local/bin. Add other install dirs as needed.
os.environ["PATH"] = f"/opt/homebrew/bin:/usr/local/bin:/usr/bin:{os.path.expanduser('~/.local/bin')}:{os.environ.get('PATH', '')}"
inp = json.load(sys.stdin)
if inp["phase"] == "list":
sheds = json.loads(subprocess.run(["shed", "list", "--json"],
capture_output=True, text=True).stdout or "[]")
items = [{"id": s["name"], "title": f"shed: {s['name']}", "subtitle": s["ssh"]}
for s in sheds if s["status"] == "running"]
if not items:
items = [{"id": "_none", "title": "No running sheds", "actionable": False}]
json.dump({"items": items}, sys.stdout)
else:
tab = inp["active_tab"]
roostctl = os.environ.get("ROOST_ROOSTCTL", "roostctl") # Roost's own CLI
# Pass the shed name as a positional arg ($1), not interpolated.
subprocess.run([roostctl, "tab", "open", "--project-id", tab["project_id"],
"--after-tab", tab["id"], "--focus",
"--", "/bin/bash", "-lc", 'shed console "$1"', "shed", inp["selected_id"]])
#!/usr/bin/env -S node
const { execFileSync } = require("child_process");
// Minimal launch PATH? Cover where shed lives: Homebrew (Mac), /usr/bin
// (Linux .deb), ~/.local/bin. Add other install dirs as needed.
process.env.PATH = `/opt/homebrew/bin:/usr/local/bin:/usr/bin:${process.env.HOME}/.local/bin:${process.env.PATH ?? ""}`;
const inp = JSON.parse(require("fs").readFileSync(0, "utf8"));
if (inp.phase === "list") {
const sheds = JSON.parse(execFileSync("shed", ["list", "--json"]).toString() || "[]");
const items = sheds.filter((s: any) => s.status === "running")
.map((s: any) => ({ id: s.name, title: `shed: ${s.name}`, subtitle: s.ssh }));
process.stdout.write(JSON.stringify({
items: items.length ? items : [{ id: "_none", title: "No running sheds", actionable: false }],
}));
} else {
const t = inp.active_tab;
const roostctl = process.env.ROOST_ROOSTCTL ?? "roostctl"; // Roost's own CLI
// Shed name as a positional arg ($1), not interpolated into the script.
execFileSync(roostctl, ["tab", "open", "--project-id", t.project_id,
"--after-tab", t.id, "--focus", "--", "/bin/bash", "-lc", 'shed console "$1"', "shed", inp.selected_id]);
}
Advanced — multi-step wizards¶
activate is recursive: when it prints {items}, Roost drills into a
sub-frame, and picking a row there runs activate again with that row's
id. That's the whole mechanism behind a wizard — chain several prompts
with no new API. Three things make it work:
listis step 1; eachactivateis the next step. Return moreitemsto advance; print nothing to finish (the palette closes).- Carry state in the row id.
activateonly ever receives the one selected id — there's no cross-step memory — so encode the choices so far into each row's id, a continuation token likeimage=base&cpus=4&mem=8192. The script is a state machine keyed on which keys the id already carries. - Run slow work in the new tab, not in
activate.activatehas a ≤60s timeout, so anything lengthy (a build, a VM create) must be launched inside a tab you open — which also shows progress live.activateitself returns immediately.
The example below is a "Create shed" wizard: it requires a Git project
root (a non-selectable error row otherwise — see
actionable), walks image → CPUs → memory → disk, then
opens a tab that runs shed create (mounting the current directory via
--local-dir) and drops into the shed. The name is derived from the
folder, sanitized to alphanumerics, and de-duplicated. Context is read from
the $ROOST_* env vars (above); you could equally parse
the stdin JSON like the basic example. Drop it at
~/.config/roost/providers/create-shed.py, chmod +x.
#!/usr/bin/env python3
# @roost.label: Create shed
"""Create-shed wizard: image → CPUs → memory → disk, then create a shed for
the current directory and open `shed console`. See the notes above for the
state-in-the-id and run-in-the-tab patterns."""
import json
import os
import re
import subprocess
import sys
# Roost may launch with a minimal PATH, so cover where `shed` lives: Homebrew
# (Mac), /usr/bin (Linux .deb), ~/.local/bin. Add other install dirs as needed.
# (roostctl comes from $ROOST_ROOSTCTL, so it needn't be on PATH.)
os.environ["PATH"] = f"/opt/homebrew/bin:/usr/local/bin:/usr/bin:{os.path.expanduser('~/.local/bin')}:{os.environ.get('PATH', '')}"
CPUS = [1, 2, 4, 8]
MEMS = [2048, 4096, 8192, 16384] # MB
DISKS = ["10G", "20G", "50G"]
def emit(items):
json.dump({"items": items}, sys.stdout)
def run(*args):
"""Run a command; return its stdout ('' on failure)."""
try:
p = subprocess.run(args, capture_output=True, text=True)
return p.stdout if p.returncode == 0 else ""
except OSError:
return ""
def field(sel, key):
"""Pull a value out of an 'a=1&b=2' id."""
for part in sel.split("&"):
k, _, v = part.partition("=")
if k == key:
return v
return ""
def shed_name(cwd):
"""basename → alphanumerics (fallback 'shed'), then -2/-3 on collision."""
base = re.sub(r"[^A-Za-z0-9]", "", os.path.basename(cwd)) or "shed"
try:
taken = {s["name"] for s in json.loads(run("shed", "list", "--json") or "[]")}
except (ValueError, KeyError, TypeError):
taken = set()
name, n = base, 2
while name in taken:
name, n = f"{base}-{n}", n + 1
return name
# Step rows. `p` is the accumulated id prefix, folded into each new id.
def cpu_rows(p):
return [{"id": f"{p}&cpus={c}", "title": f"{c} vCPU"} for c in CPUS]
def mem_rows(p):
return [{"id": f"{p}&mem={m}", "title": f"{m // 1024} GB", "subtitle": f"{m} MB"} for m in MEMS]
def disk_rows(p):
return [{"id": f"{p}&upper={d}", "title": f"{d} writable layer"} for d in DISKS]
def do_list(cwd):
# Gate: only a Git project root (a .git entry; a subdir has none, so this
# also enforces "at the root").
if not os.path.exists(os.path.join(cwd, ".git")):
emit([{"id": "_nogit", "title": "Project not found (no .git)",
"subtitle": "create shed only works at a repo root", "actionable": False}])
return
try:
imgs = json.loads(run("shed", "image", "ls", "--json") or "{}").get("images", [])
except ValueError:
imgs = []
if not imgs:
emit([{"id": "_none", "title": "No images", "actionable": False}])
elif len(imgs) == 1: # auto-skip the image step when there's only one
emit(cpu_rows(f"image={imgs[0]['name']}"))
else:
emit([{"id": f"image={i['name']}", "title": i["name"],
"subtitle": i.get("docker_ref", "")} for i in imgs])
def do_activate(cwd, sel):
if "upper=" in sel: # all four chosen → create in a new tab
name = shed_name(cwd)
roostctl = os.environ.get("ROOST_ROOSTCTL", "roostctl")
shell = os.environ.get("SHELL", "/bin/bash")
# Create + console run in the tab (see notes). On failure, drop to an
# interactive shell so the error stays visible.
inner = (
'if shed create "$1" --local-dir "$2" --image "$3" '
'--cpus "$4" --memory "$5" --upper-size "$6"; then\n'
' exec shed console "$1"\n'
'else\n'
' echo; echo "shed create failed (see output above)."\n'
' exec "${SHELL:-/bin/bash}" -i\n'
'fi'
)
subprocess.run([
roostctl, "tab", "open",
"--project-id", os.environ.get("ROOST_ACTIVE_PROJECT_ID", ""),
"--after-tab", os.environ.get("ROOST_ACTIVE_TAB_ID", ""),
"--focus", "--title", f"shed: {name}",
"--", shell, "-lc", inner,
"shed", name, cwd,
field(sel, "image"), field(sel, "cpus"), field(sel, "mem"), field(sel, "upper"),
])
elif "mem=" in sel:
emit(disk_rows(sel))
elif "cpus=" in sel:
emit(mem_rows(sel))
elif sel.startswith("image="):
emit(cpu_rows(sel))
def main():
phase = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ROOST_PROVIDER_PHASE", "")
cwd = os.environ.get("ROOST_ACTIVE_CWD", "")
if phase == "list":
do_list(cwd)
elif phase == "activate":
do_activate(cwd, os.environ.get("ROOST_SELECTED_ID", ""))
if __name__ == "__main__":
main()
No free-text input (v1). Every step is a selection — Roost has no "type a value" prompt. Derive what you can (here the name comes from the directory) or pre-list options; a typed name/port/path isn't expressible yet.
palette.present — let a script drive its own menu¶
If you'd rather your script own the whole flow (gather options, show a
menu, act), use palette.present: hand Roost a list, get back the choice.
It blocks until the user picks or dismisses.
items='[{"id":"web","title":"shed: web"},{"id":"api","title":"shed: api"}]'
choice=$(roostctl palette present --title "Open shed" --items "$items" --json | jq -r .selected_id)
[ -n "$choice" ] && shed open "$choice"
Items can also be piped on stdin (… | roostctl palette present). This is
the same {id, title, subtitle?} item shape providers print — a provider
is just the Roost-driven version of the same contract. (One v1 difference:
palette.present rows are always selectable — the actionable flag is
honored for provider rows only, not over the palette.present wire.)