Elate
Skill dakra/elate/skills/elate
Spawns sandboxed Emacs sessions (terminal or GUI) and drives them with keys, mouse, and elisp to test Emacs Lisp interactively - run ERT tests in a live session, inspect faces/overlays/popups structurally, lint, profile, benchmark, capture screenshots, and turn sessions into replayable regression scripts. Use when developing or debugging an Emacs package or config, when asked to test-drive, reproduce a bug in, or verify behavior of Emacs Lisp code, or when batch `emacs --batch`/ERT alone cannot show interactive behavior (keybindings, minibuffer prompts, transient menus, redisplay).From its SKILL.md
npx -y skills add dakra/elate --skill elateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
26.2 KB, as published. Nobody here has run it
elate — drive a real Emacs from the shell
elate spawns disposable, sandboxed Emacs sessions (fresh fake $HOME,
generated init, private tmux server) and gives you structured control and
observation over them. Run it with uvx elate … (PyPI; no install step),
or uv tool install elate once to put elate on PATH (handy when many
sub-agents each shell out). In a checkout of the elate repo itself, use
uv run elate … instead. After claude plugin update, the CLI (uvx/uv run) is already on the new version while a registered MCP server (if you
enabled one) stays on the old one until the client restarts — so
mid-session the CLI is the live path.
Supporting files (read on demand):
- REFERENCE.md — every command, option, default (generated from the CLI)
- RECIPES.md — worked examples: transient menus, font-lock, dired, clean-install
- SCRIPTING.md — scenario JSON format, assertions,
run/matrix/export-script, CI
Quickstart
uvx elate start --name s --load ./my-pkg.el # sandboxed TTY Emacs, pkg on load-path
uvx elate -s s eval '(my-pkg-mode 1)' # act
uvx elate -s s wait idle # synchronize
uvx elate -s s state # observe
uvx elate stop s # ALWAYS stop your sessions when done
Rules that prevent the most common mistakes:
- Always name sessions (
--name), alwayselate stop NAMEwhen done.elate listshows leftovers; stop them.startwithout--nameauto-generates anelate-<hex>name (returned in the result);start --name X --replacestops and recreates a liveX.stopis idempotent (stopping a missing session is a no-op success), andstop --allstops every running session at once. - Most commands need
-s NAMEbefore the subcommand:elate -s s eval …. - The sandbox
$HOMEis fake: create fixture files viaeval((with-temp-file "~/f" …)) so nothing touches the real home. There is no network inside the sandbox. For files a subprocess needs at spawn (e.g. shell rc files for shell-integration tests), usestart --home-seed DIR— it copies a fixture tree into the fake$HOMEbefore Emacs launches, keeping isolation (don't pointHOMEat a real dir). - Every session has a private scratch directory for your setup files
and artifacts:
elate -s NAME pathprints it (bare path — substitutes into shell commands), and in-Emacs code sees it as$ELATE_SCRATCH((getenv "ELATE_SCRATCH")). Use it instead of a shared temp dir — concurrent agents sharing one scratchpad overwrite each other's files; the scratch dir is purged with the sandbox. - Startup forms run before
emacs-startup-hook(set vars an auto-launch hook reads): inline--eval FORM, or — to reuse the same setup across sessions instead of re-pasting it —--eval-file PATH(a forms file, noload-pathside effects) and--profile NAME($XDG_CONFIG_HOME/elate/profiles/NAME.el). Order:--load→--eval-file→--profile→--eval(so--evaloverrides a profile). --load PATH: a file is loaded (and its directory put onload-path); a directory is only added toload-path— nothing in it is loaded, sorequire/load the feature yourself or pass the.elfiles. Use--config clean-install --load pkg.elto instead install the package for real (autoloads, Package-Requires, byte-compilation verified; see RECIPES.md).
The loop: act → wait → state
Never sleep-and-poll. Never assume an effect happened — observe it.
- Act:
keys/type/mouse/eval. - Wait for the effect (exit 3 = timed out, the condition never held):
uvx elate -s s wait stable --buffer '*shell*' --quiet-ms 300 # output settled uvx elate -s s wait idle # command loop went quiet uvx elate -s s wait text 'Compiled OK' --buffer '*compilation*' --timeout 30 uvx elate -s s wait prompt # a minibuffer prompt opened uvx elate -s s wait until '(eq major-mode (quote my-mode))' --timeout 5wait texttakes a Python regexp (not elisp syntax!) and happily polls a buffer that does not exist yet. For subprocess / REPL / terminal output (comint, compilation, vterm, async LSP), reach forwait stable— it returns once the buffer's text has not changed for--quiet-msms, which is the "did the output stop?" question.wait idleis command-loop idle (itsidlenumber is just seconds since the last activity — a big value is healthy, not a hang) and says nothing about whether output finished. For any condition that is neither text nor quiescence (a mode change, a marker position, process state),wait until '<pred>'polls an elisp predicate until non-nil and returns its value — never write an eval-poll loop. An elisp error from the predicate fails the wait immediately (so a typo can't hide until the deadline); wrap the form inignore-errorsif an error just means "not yet".--buffer Bevaluates it in a buffer. - Observe:
stateis the one-call scene snapshot (buffer, mode, point, window layout, minibuffer prompt + completions, echo area, active popup kinds, Messages tail). When confused, runstatefirst — it almost always explains what happened. Targeted reads:buffer,messages(delta since last call),echo,popups,screenshot, andlogs(the Emacs stderr tail — module panics, GC/native-comp warnings, the fatal-signal line on a crash; works on dead/stopped sessions too).
Key delivery: which mode when
keys takes Emacs kbd notation ('C-x C-f', 'M-x foo RET', 'TAB').
| situation | use |
|---|---|
| normal key sequence that completes | keys 'M-x foo RET' (semantic, default) |
| sequence that opens a prompt and leaves it open | keys 'C-x C-f' --events |
| answering an already-open prompt | type 'filename' then keys RET --events |
| Emacs is wedged/busy, nothing responds | interrupt (raw C-g on TTY; signals a GUI Emacs) |
| literal text into a buffer | type 'hello' (or eval '(insert …)' for bulk) |
| drive a subprocess (shell/REPL): ^C, feed input | send-process --char C-c / send-process 'cmd\n' |
Why: semantic delivery runs execute-kbd-macro, which runs the keys
through the command loop — so they obey whatever keymaps are active.
In an evil buffer in normal state, type "abc" sends the commands a,
b, c, not the text; enter insert state (or use --raw) first. And it
does not block on an open minibuffer prompt — it exits the prompt with
empty input (bare M-x errors with "'' is not a valid command name").
--events queues on unread-command-events instead, so the prompt stays
open for you to inspect (state shows prompt + candidates) and answer.
- Because keys obey the focused buffer's keymaps, a buffer that intercepts
keys can swallow one: a terminal emulator in char mode (vterm/eat,
and the like) forwards most keystrokes to its PTY, and many special-mode
buffers rebind keys, so a globally-bound key never runs as the command
you meant. The result's
commandfield is what the sequence resolves to in the focused buffer (nullfor an unbound key or a multi-command sequence) — assert on it instead of inferring fromdelivered:keys '<f8>'returning"command": "term-send-raw"tells you the terminal ate the key. When a command must run regardless of buffer bindings, call it directly witheval. --rawsends real terminal bytes via tmux: works even when Emacs is stuck, but rejects chords a terminal cannot encode (e.g.C-%) and does not exist for GUI sessions. To simply unwedge a busy Emacs, preferinterrupt(below) over raw key plumbing.- A command that rings the bell aborts the whole semantic macro. elate
reports the culprit —
key delivery aborted -- COMMAND rang the bell in BUFFER at point N— so diagnose withdescribe key …/messages. To deliver past a spurious bell (e.g. evil insert off the prompt row), usekeys … --no-abort-on-bell(queued via events, so asynchronous — follow with await). send-processwrites straight to a buffer's subprocess (process-send-string), bypassing the command loop:--char C-cinterrupts a job,send-process 'cmd\n'feeds a shell/REPL,--fileseeds a large payload.keys/typedrive Emacs; this drives the process.--buffer NAMEtargets any buffer with a live subprocess — ashell,comintREPL, or a terminal buffer (term/vterm/eat). To drive a program running inside a terminal buffer, send its bytes there directly, e.g.send-process --buffer '*ghostel*' --char 'C-c'thensend-process --buffer '*ghostel*' 'git status\n'. Targeting rule: the buffer must have exactly one live process — none or several is an error naming the candidates, never a silent pick; pick one with--process NAME(by process name, buffer optional). The result echoes the chosen process's name and command line — check it when input seems to vanish: a package can also write through its own channel (a raw fd its Emacs process object doesn't front), in which case no process is the write path and you call the package's send function viaevalinstead.- The sandboxed frame never has real window-system focus while an agent
drives it, so code gated on focus (paste-on-focus, focus-dimming, a
click-to-refocus mode) sees an unfocused frame and behaves differently.
Don't fight it with window managers: inject focus with
focus in/send-events 'focus-in' …(ordered with clicks/keys), and add--set-focus-statewhen the code reads(frame-focus-state)— that C-owned state cannot be moved from elisp, so the flag shims it. Worked example: the focus-vs-click recipe in RECIPES.md. - If a semantic
keyscall times out, the sequence probably left Emacs reading input: retry with--events, or recover withinterrupt. - After an eval/keys timeout where Emacs stays busy (
infoshowsbusy: true):interruptunblocks it — raw C-g on TTY; on GUI it breaks Emacs into the Lisp debugger with SIGUSR2 and unwinds it back to top level (--signal usr2instead stays in the debugger sodebug showreveals where it was stuck). Never use--signal intto recover: SIGINT terminates a GUI-only Emacs. Stop the session only if it stays wedged after an interrupt. - A session sitting in the Lisp debugger (code under test signalled and
*Backtrace*popped up) is the opposite failure mode: it answers the channel and looks idle, but every key/eval lands inside the debugger.inforeportsin_debugger/recursion_depthandstatereportsin-debugger/recursion-depth(note the spelling difference),wait idlefails fast pointing there;debug showprints the backtrace,debug abortunwinds to top level with the session intact.
Eval gotchas
uvx elate -s s eval '(my-fn 42)' --timeout 5
- Forms run in the selected window's buffer by default (so
current-buffer/point see what's on screen); target another buffer with--buffer NAMEor wrap the form:eval '(with-current-buffer "*scratch*" (insert "hi"))'. - Shell single-quoting cannot contain
'— so#'fnand'symbolbreak as argv. Don't rewrite the elisp: put the forms in a file andeval --file forms.el(or--file -for stdin) to run them verbatim. - Errors come back structured:
error+backtrace+ the Messages delta; exit code 1. - Printed values are truncated at 64 KiB (
truncated: true+value-lengthin--json). Don't pass huge strings as arguments either (~1 MiB argv limit) — write a temp file andloadit. --timeout(default 15s) bounds the eval both in-Emacs and at the client; raise it for a legitimately slow form (spawn, compile, package install) rather than letting it abort. A form stuck insleep-for/sit-for/process waits is interrupted by--timeout; a synchronouscall-processor a hard elisp loop ignores it — recover withinterrupt.- Predicates often return a truthy value, not
t:(process-live-p p)yields the status tail(run open listen connect stop), nott. Wrap with(and … t)(or(if … t nil)) when you want a clean boolean back. - The default
valueis a printed sexp string. For structured probes add--json-result(real JSON invalue) or--raw(bare value, no envelope) — see "JSON output and exit codes" below; never regex a printed plist. - If a still-busy timeout needs where it is stuck, add
--on-timeout sample: it attaches a thread backtrace of the wedged Emacs (macOSsample; Linux eu-stack/gdb) to the timeout error assample. - If a form crashes Emacs,
evalreturnssession_died: truewith the fatalsignaland the OScrash_reportpath (instead of an opaque transport error).wait deadblocks until the session exits and returns the same;info/listshow a dead session's signal (e.g.dead (SIGABRT)). Read the stderr withlogs.
Verify rendering structurally, not by eyeballing
Screenshots are for humans; assertions should read structure:
uvx elate -s s buffer demo.el --props # RLE face/property runs + overlays
uvx elate -s s faces-at 3:14 --buffer demo.el # LINE:COL (1-based:0-based)
uvx elate -s s faces-at --pos 420 --run 3 # by position; 3 adjacent cells at once
uvx elate -s s popups # transient/which-key/corfu/childframes as text
--propsrunsfont-lock-ensurefirst, so never-displayed buffers fontify correctly.faces-atreports every text property at the point, with values (property-values: e.g. your ownmy-prompt=tvsmy-count=42) — use it to assert a package's custom text properties instead of repeatedeval (get-text-property …). Address byLINE:COLor--pos N(a buffer position, handy from elisp);--run Kdumps K adjacent cells in one call (compare a typed cell against the dimmed suggestion beside it). Over MCP it iselate_faces_at(pos/run).state'spopupsfield tells you when apopupscapture is worthwhile.- TTY
screenshotprints the rendered screen as text (works post-mortem on a crashed Emacs); GUIscreenshot -o x.pngwrites a PNG you can Read. - GUI capture on macOS needs an awake, unlocked display (
--headless/Xvfb is Linux-only). A failed capture reports areasonin the error JSON —locked/display_asleep/window_gone/permission— so a locked or asleep Mac is distinguishable from a missing Screen Recording grant. For an unattended/CI Mac, keep a real GUI login awake and unlocked (auto-login + disable screen-lock +caffeinate -dimsu); a backgroundedlaunchdrunner has no GUI session and always captures black.
Trace internal functions — don't hand-roll advice spies
To see which internal functions ran, in what order, with what args (what
bytes hit the PTY? why did the hook fire twice?), use the built-in tracer
instead of writing advice-add wrappers that record calls:
uvx elate -s s trace on my-pkg--send my-pkg--filter # start recording
uvx elate -s s keys 'x' # drive the session
uvx elate -s s trace read # calls+args+returns, in order; clears
uvx elate -s s trace off # untrace all
Each call records nesting, arguments, and return value. read clears the
log, so each read sees only new calls (--keep to accumulate). Trace a
handful of named functions, not a whole package — tracing is per-function.
With --json, read also carries structured records — {fn, depth, args, ret, error} per call, in completion order (a nested call precedes
its caller; depth 1 = outermost) — assert on those, never regex the raw
output text.
Tests, lint, profile, bench — fresh sessions only
Results depend on session history (earlier loads/lints/evals skew them). For an authoritative verdict, use a fresh throwaway session per run.
uvx elate -s s test --load-file tests/my-tests.el # all tests
uvx elate -s s test 'my-pkg-' --timeout 30 # name regexp
uvx elate -s s test '(tag ui)' # any ERT selector
ERT runs inside the live session — real redisplay, real minibuffer — and returns structured per-test results (status, duration, messages, condition, backtrace). Exit 0 = all expected, 1 = unexpected/timed out. A timed-out run returns partial results and names the interrupted test.
uvx elate -s s lint my-pkg.el
Lint executes the files' compile-time code (eval-when-compile, macro
expansion, top-level require) in the session — that is inherent to
in-session linting. Lint untrusted files only in a throwaway session you
stop afterwards. Exit 1 on any finding.
uvx elate -s s profile run '(my-pkg-heavy)' --timeout 30 # start→eval→stop→report
uvx elate -s s bench '(my-pkg-parse s)' -n 100
From interactive session to regression test
Everything you did in a session is in its transcript. Turn it into a replayable script:
uvx elate -s s export-script --clean -o scenario.json # --clean strips transient temp paths
# edit: observations were exported as skipped assertion stubs — fill them in
uvx elate run scenario.json # fresh session, steps, exit 0/1
uvx elate run scenario.json --keep-going --format junit # every check + a CI report
uvx elate matrix scenario.json --emacs emacs30,emacs31 --param shell=/bin/bash,/bin/zsh
elate run boots a fresh session per run and exits 0/1 — the CI entry
point; by default it stops at the first failure (embedding a state
snapshot) and purges its sandbox on success (failed runs are kept —
purge --glob 'run-*' sweeps them). For a regression matrix that reports
what does not work: --keep-going runs every step; a step marked
"expect": "fail" reports xfail (a known break that never gates, and
flips to a run-failing xpass if it starts passing); a {"group": "dw"}
marker names verdicts (dw: PASS · u: XFAIL); --format junit/tap
emits CI-ready output; and {{var}} + --set / matrix --param drive
one scenario across many shells/configs. When several variables must move
together (a shell + its aliases + its setup snippet), declare a
"variants" block of named binding sets: matrix runs every variant by
default, run --variant NAME picks one. --keep/--keep-on-failure
keep the session for inspection. Scenario format, every step and
assertion kind: see SCRIPTING.md.
JSON output and exit codes
Output is the human table on a terminal and JSON when stdout is not a TTY
— i.e. you get clean JSON automatically when piping or running headless, no
flag needed. Force it either way with the global --json / --human (before
the subcommand):
uvx elate -s s eval '(emacs-version)' | cat # piped → {"ok": true, "value": …}
uvx elate --json -s s eval '(emacs-version)' # force JSON even on a terminal
uvx elate --human -s s list | less # force the table even when piped
Parse the JSON — don't scrape the human table (eval --json gives value,
value-length, truncated, error, backtrace, messages). Errors embed a
state snapshot so you see why. Exit codes: 0 success, 1 error (elisp
errors, test failures, lint findings), 2 CLI usage error, 3 wait
timeout. Branch on them in shell loops — elate -s s eval '(my-check)' && next-step needs no output parsing at all.
Don't double-parse eval results. By default eval's value is the
printed sexp as a string — JSON tooling can't take it apart, and regex/
string surgery on it is a bug farm. Instead:
# Real JSON inside the envelope: jq works on the value itself.
uvx elate --json -s s eval --json-result \
'(list :mode major-mode :ro buffer-read-only :point (point))'
# → "value": {"mode":"lisp-mode","ro":false,"point":316}, "value-encoding":"json"
# No envelope at all: the bare value, straight into shell tests.
[ "$(uvx elate -s s eval --raw 'major-mode')" = lisp-mode ]
uvx elate -s s eval --raw --json-result '(list :a 1)' # bare {"a":1}
# Any command: print one envelope field bare (global flag).
uvx elate --field name -s s info
--json-result converts in-session: nil → null (by rule — elisp can't
tell nil/false/empty-list apart), t → true, symbols → their names,
keyword plists/alists/hash-tables → objects, other lists and vectors →
arrays. A value with no faithful JSON shape (buffers, markers, circular
structures) falls back to the printed string — check value-encoding
("json" vs "printed"), never guess. With --raw/--field, a failed
command prints nothing to stdout (error on stderr, normal exit code), so
$(…) substitutions compare against emptiness, not an error blob. And
prefer the structured commands (state, faces-at, info) over eval
probes — they already return real JSON.
GUI sessions (when TTY isn't enough)
uvx elate start --name g --ui gui --size 100x35 --load ./my-pkg.el
uvx elate -s g screenshot -o shot.png # real PNG; Read it
uvx elate -s g mouse click --buffer '*menu*' --line 3 --col 5
Mouse is semantic (a real event sequence through the command loop — no OS
permissions) and works on TTY too. GUI differences: no --raw channel;
type is capped at 10,000 chars; macOS PNG capture needs the Screen
Recording permission (elate preflights and reports instead of prompting).
On Linux/CI add --headless for a private Xvfb.
Three GUI-level commands reach below the command loop, where
special-event-map code (drag-and-drop in particular) lives:
uvx elate -s g window-info # X11 ids as ints, pixel edges per window
uvx elate -s g pointer warp --buffer '*scratch*' --line 3 --col 5
uvx elate -s g dnd drop --uris file:///tmp/a,file:///tmp/b --buffer dired
pointer moves/reads the REAL pointer (mouse synthesizes command-loop
events; dnd code reads the live pointer instead, so only pointer can
target it). dnd drop runs a full XDND exchange from an external X
client — the drop travels Emacs's C event dispatch and x-dnd.el exactly
like a user drag; X11 sessions only (--ui gui --headless on Linux),
needs pip install 'elate[dnd]'. A refused drop returns
status: "rejected" with exit 0. A drop handler that errors is
reported, not hidden: Emacs catches it and clears XdndFinished's
success bit — finished-success: false, error text in messages;
results also carry in-debugger for handlers that park the Lisp
debugger instead (inspect with debug show). --hover asserts drag
feedback without dropping. After a drop, wait stable --buffer B is
the settle primitive.
When the MCP server fits better
This skill drives the CLI via shell — the full feature set with near-zero
ambient cost. Prefer the MCP server only when there is no shell access
(e.g. desktop apps), or when you want GUI screenshots returned inline as
images instead of PNG files to read. If elate_* MCP tools are already
available in your session (someone registered the server — the plugin is
CLI-first and does not register it for you), use them directly — do not
register a duplicate; otherwise register it with
claude mcp add elate -- uvx elate mcp. The 35 elate_* tools cover the core surface (attach, resize,
prune, stderr, export-script, snap, matrix, install, and update stay CLI-only); prune
aliases purge and stderr aliases logs. Sessions are shared
between both (same names, same sandboxes), so you can mix.
Cleanup checklist (always)
uvx elate list # anything still running?
uvx elate stop NAME # stop every session you started
elate stop ends the session's processes; a crashed TTY Emacs keeps a
dead pane for post-mortem screenshot until stopped. A stopped session's
sandbox dir — and its stopped entry in elate list (which shows each
one's idle age) — stays behind on purpose (transcripts outlive the Emacs).
Stopped sandboxes are inert; when the transcripts are no longer needed,
elate purge NAME… (or elate purge --all; prune is the same command)
deletes them — purge never touches a running session. elate run purges
its own throwaway sandbox on success, so only failed runs pile up — named
run-<scenario>-<hex> after the scenario file, so a post-mortem is
findable: sweep them by pattern with elate purge --glob 'run-*' (or a
targeted --glob 'run-my-scen-*', or --name-prefix run-). During a
long parallel run, GC only the stale ones with elate purge --stopped-older-than 1h, and preview which they are with
elate list --older-than 1h. Sandboxes live under ~/.cache/elate/sessions/<name>
($ELATE_HOME overrides the base).
Several agents on one machine: tag your sessions at start and manage only your own —
uvx elate start --name rx-a --owner agent3 --ttl 30m
uvx elate list --owner agent3 # just mine
uvx elate stop --owner agent3 # stop all of mine (also: --glob/--name-prefix)
uvx elate purge --owner agent3 # delete my stopped sandboxes
--ttl DUR is the crash insurance: a session idle (no commands) past its
TTL is stopped and purged by an opportunistic sweep that any later elate
command runs — so sessions leaked by a crashed agent reap themselves. Only
TTL'd sessions are ever swept; the session a command targets is exempt.