Scripting with Rhai¶
cub embeds Rhai, a small scripting language for
automating an outline. Scripts can run from the command line or from the
TUI, and they all use the same Doc and Node API.
Choose how to run a script¶
| Use | Best for | How the script gets the outline |
|---|---|---|
cub run SCRIPT.rhai |
Repeatable tasks, checks, and CI | Call open(path) in the script. |
@action name |
A short command stored in an outline | doc and the selected node are predefined. |
@import path/to/script.rhai |
Commands shared between outlines or kept in source control | Export functions that receive the open document and selected node. |
Start with cub run when experimenting outside the editor. Use an @action
when the behavior belongs in one outline, and @import when it belongs in a
regular source file.
cub SCRIPT.rhai (no run) works too -- the same shorthand as cub
notes.leo opening the TUI, dispatched by the file's .rhai extension.
Run your first script¶
Create notes.rhai:
let doc = open("notes.leo");
let task = doc.ensure("Project/Tasks/Write docs");
task.b = "Cover the Rhai API.";
assert_eq(task.h, "Write docs");
doc.save();
print("wrote " + doc.count() + " nodes");
Create the outline if it does not exist, then run the script:
open() reads the outline into a Doc. ensure() creates any missing
segments of a slash-separated headline path and returns the final node.
Changing task.b updates that node's body in memory. The file is not written
until doc.save() or doc.save_as(path) is called.
If parsing fails, a method throws, or an assertion fails, cub run exits
non-zero. This makes the same kind of script useful both interactively and in
CI.
Work with nodes¶
A Node is the usual way to read, edit, and traverse an outline:
let doc = open("notes.leo");
let tasks = doc.ensure("Project/Tasks");
doc.ensure("Project/Tasks/Write onboarding docs");
doc.ensure("Project/Tasks/Ship v2");
for child in tasks.children() {
print(child.h);
}
for todo in doc.find_h("^TODO") {
let h = todo.h;
h.replace("TODO", "DONE");
todo.h = h;
}
doc.save();
The properties and methods used most often are:
| API | Purpose |
|---|---|
node.h, node.b |
Read or change the headline and body. |
node.parent() |
Get the parent node. |
node.children() |
Get the direct children in outline order. |
node.subtree() |
Get this node and all its descendants. |
node.path() |
Get its slash-separated headline path. |
node.gnx |
Get its stable Leo node id. |
doc.ensure(path) |
Find or create a headline path. |
doc.find_h(regex), doc.find_b(regex) |
Search headlines or bodies. |
See the Rhai API reference for the complete API, including GNX-based operations, clones, filesystem access, and subprocesses.
Create an action in the TUI¶
An @action node is a command stored directly in an outline:
- Create a node with the headline
@action mark done. -
Put this Rhai code in its body:
-
Select the node you want to change.
- Press
Shift-A, choose mark done, and pressEnter.
An action body gets these values automatically:
| Name | Value |
|---|---|
doc |
The Doc currently open in the TUI. |
p |
A Node for the exact occurrence selected when the action was invoked. |
target |
The selected node's GNX string. Prefer p unless an API specifically needs a GNX. |
There is no open() call because the action operates directly on the TUI's
in-memory outline. Mutations mark the outline as changed, but do not write it
to disk until the usual Ctrl-S save. print() and debug() output appears
in the body pane until the selection moves.
Share commands with @import¶
For commands you want to edit as normal source files or reuse across outlines,
put the Rhai code in a file. For example, scripts/tasks.rhai could contain:
const COMMANDS = #{
mark_done: "Append a checkmark to the selected node's headline.",
list_todos: "Print every node headlined TODO.",
};
fn mark_done(doc, target) {
target.h = target.h + " ✓";
}
fn list_todos(doc, target) {
for node in doc.find_h("^TODO") {
print(node.path());
}
}
Then add a node to the outline with this headline and an empty body:
The path is relative to the .leo file. Each function COMMANDS names
appears in the Shift-A action palette, alongside the description its map
value gives; the active entry's full description shows below the list. A
command function must accept exactly (doc, target), where target is a
positioned Node for the current selection. Other functions may use any
signature, but do not appear in the palette.
Commands that depend on the selection¶
COMMANDS always offers the same commands regardless of what's selected.
For a command that only makes sense on some nodes -- say, one that expects
the selected node's body to already look like a URL -- add
available_commands(doc, target) instead of (or alongside) listing it in
COMMANDS:
fn available_commands(doc, target) {
if target.b.starts_with("https://") {
#{ open_link: "Fetch this node's URL and replace its body with the response." }
} else {
#{}
}
}
The palette calls it with the live selection each time it opens, and unions
the map it returns into COMMANDS's -- a name in both takes
available_commands's description. It runs against a throwaway copy of the
outline, so a mutating Doc call inside it has no effect; treat it as a
read-only query that decides what to offer, not something that changes
the outline itself.
The repository's scripts/github.rhai
is a larger example with both palette commands and private helper functions.
scripts/claude-history.rhai
is another: a single command, import_claude_history, that reads Claude
Code's own ~/.claude/projects/*/*.jsonl session transcripts with
open_dir/open_file/read_string and creates one outline node per
session.
Configure a script with an @variables tree¶
@variables isn't a directive cub treats specially -- it's a plain
headline convention for settings a script should read instead of
hard-coding. Put a node headlined @variables in the outline, with one
child per setting, either shape:
(a child headlined name with the value in its body, or a child headlined
name = value directly -- both are read the same way, and can be mixed in
one @variables node).
Read a setting with cub::variable(target, name) -- see the API
reference:
It walks target and its ancestors, nearest first, so @variables can live
at the outline root for one setting shared by the whole file, or nested
under a particular subtree to scope it there instead -- letting different
parts of the same outline set the same name (repo, say) to different
values. A subtree's own @variables wins over an outline-wide one further
out; an outline-wide @variables (a root of its own, not nested under
anything) still works as a fallback when nothing closer sets the name.
scripts/github.rhai uses this for its repo setting: when @variables/repo
is set to owner/name somewhere in reach of the selected node, every gh
command it runs adds --repo owner/name, targeting that repository instead
of the one implied by the current directory. Leave @variables (or the
repo child) absent entirely and gh falls back to its own default.
@variables suits settings that belong to one outline. For a value that
changes per invocation instead -- which outline to open, a one-off flag --
pass it on the command line and read it from ARGS, an array of the
strings after the script path:
ARGS is [] when no extra arguments were given, so a script that only
sometimes needs one can check ARGS.len() first.
Assertions, output, and errors¶
assert(doc.count() > 0, "outline should not be empty");
assert_eq(doc.validate().len, 0);
print("check passed");
debug(doc.render());
assert(condition)andassert(condition, message)stop on a false value.assert_eq(actual, expected)stops when the values differ.print(...)writes normal output;debug(...)includes its source position.- A parsing error, thrown API error, or failed assertion stops the script.
For a command-line script, failure produces a non-zero exit status. In an action, the error is displayed in the TUI.
Run external tools and read files¶
Use sh(command) to run a command through sh -c, relative to cub's own
working directory:
let result = sh("git rev-parse --short HEAD");
assert(result.code == 0, "git failed: " + result.stderr);
let hash = result.stdout;
hash.trim();
print(hash);
The result contains stdout, stderr, and code. A non-zero command does not
throw, so check code yourself. Rhai's trim() mutates its target in place and
returns nothing, so it needs a variable to act on -- result.stdout.trim()
discards the trimmed copy; see gotchas in
the API reference. To run relative to the open outline instead, pass #{ cwd:
doc.dir() }: sh("git status", #{ cwd: doc.dir() }).
For direct filesystem access, Rhai's file functions are available too:
The API reference lists the available path and file operations.
Apply a JSON operation batch¶
Direct Node edits are clearest for most scripts. Use doc.apply() when you
already have tree-shaped JSON or need several operations to be transactional:
let doc = open("notes.leo");
let report = doc.apply(`{
"operations": [
{"op": "insert-tree", "parent-headline": "Imports/PRs",
"tree": {"PR #142": {"_body": "Ready for review"}}}
]
}`);
print(report);
doc.save();
See Automation and AI tools for operation formats such as
insert-tree, merge-tree, and replace-tree.
Clones and destructive edits¶
A GNX identifies a node, while a position identifies one occurrence of it. This only matters when a node is cloned into more than one place:
- Nodes reached through
p,node_at(),children(), orsubtree()retain their exact position. - Nodes reached only by GNX may fall back to the first occurrence.
node.remove()removes the exact occurrence when the node has a position.
For destructive work involving clones, start from a positioned node. The API reference explains the detailed behavior.
If you know Leo's Python scripting¶
The names are different, but the everyday shape should feel familiar:
| Leo | cub Rhai |
|---|---|
c |
doc |
p |
p in an action, or another Node |
p.h, p.b |
node.h, node.b |
p.children() |
node.children() |
g.es(...) |
print(...) |
cub deliberately exposes a smaller outline-focused API. A Node is stable
by GNX and may also carry a position to distinguish clones; it is not a live
cursor over the full Leo application.
Keep scripted changes safe¶
- Keep important outlines under version control or make a backup first.
- Remember that
cub runwrites only when the script callssave(). - Remember that TUI actions change memory immediately but use the normal save
flow unless they explicitly call
save(). - Assert assumptions before destructive edits.
- Call
doc.validate()after a substantial batch of changes. - Use
cub apply --dry-runwhen developing external JSON operation batches.