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 a project named "review", create it if missing, open a tab, run a command.
name="review"
pid=$(roostctl tab list --json | jq -r --arg n "$name" \
'.projects[] | select(.name==$n) | .id' | head -1)
[ -z "$pid" ] && pid=$(roostctl project create --name "$name" --cwd "$PWD" \
| jq -r '.project.id')
roostctl tab open --project-id "$pid" --title "$name" -- bash -lc 'make test'
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:
See the CLI reference and the IPC wire format for the full op set.
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. - Stdin JSON:
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.)