Recipe Format
Recipes are Rhai scripts that define how to acquire, build, and install packages. Each recipe uses a context map (ctx) to pass state between lifecycle phases and implements check/action function pairs.
Basic Structure
A recipe is a .rhai file with a ctx map for metadata and paired lifecycle functions: each action has an is_* check function that returns the context or throws.
let ctx = #{
name: "mypackage",
version: "1.0.0",
description: "Short description",
// Custom fields for tracking paths between phases
source_dir: "",
build_dir: "",
};
fn is_acquired(ctx) {
// Check if source exists, return ctx with paths filled in
let src = join_path(BUILD_DIR, "mypackage-" + ctx.version);
if is_dir(src) {
ctx.source_dir = src;
return ctx;
}
throw "source not acquired";
}
fn acquire(ctx) {
let url = "https://example.com/" + ctx.name + "-" + ctx.version + ".tar.gz";
let archive = download(url, join_path(BUILD_DIR, ctx.name + ".tar.gz"));
extract(archive, BUILD_DIR);
ctx.source_dir = join_path(BUILD_DIR, ctx.name + "-" + ctx.version);
ctx
}
fn is_built(ctx) {
let binary = join_path(ctx.source_dir, "mypackage");
if is_file(binary) {
return ctx;
}
throw "not built";
}
fn build(ctx) {
shell_in(ctx.source_dir, "./configure --prefix=" + PREFIX);
shell_in(ctx.source_dir, "make -j" + NPROC);
ctx
}
fn is_installed(ctx) {
if is_file(join_path(PREFIX, "bin/mypackage")) {
return ctx;
}
throw "not installed";
}
fn install(ctx) {
shell_in(ctx.source_dir, "make install DESTDIR=" + PREFIX);
ctx
}Context Map (ctx)
The ctx map holds package metadata and state that flows through lifecycle phases:
| Field | Type | Description |
|---|---|---|
| name | String | Package name (alphanumeric, hyphens, underscores) |
| version | String | Package version |
| description | String | Human-readable description |
| (custom) | Any | Add any fields needed to track paths, state, etc. |
Functions receive ctx as a parameter and return it (possibly modified) or throw on error. This enables caching - if is_acquired(ctx) succeeds, acquire(ctx) is skipped.
Living ctx Persistence
The ctx map is a "living" object that persists across invocations. After each lifecycle phase completes, the Rust engine writes the modified ctx back to the .rhai file on disk.
This persistence model provides three key benefits:
- Caching - If
is_acquired(ctx)succeeds,acquire(ctx)is skipped, butctxstill has paths filled in from the saved state - Resumability - If a build crashes halfway, re-running picks up from the saved
ctxstate - Introspection - You can see the current state by reading the recipe file directly
Example: after acquire completes, the recipe file on disk is updated:
// Before acquire runs:
let ctx = #{
name: "ripgrep",
version: "14.1.1",
extract_dir: "", // Empty - not yet known
};
// After acquire completes, the file on disk becomes:
let ctx = #{
name: "ripgrep",
version: "14.1.1",
extract_dir: "/tmp/recipe-abc123/ripgrep-14.1.1-x86_64-unknown-linux-musl",
}; The next time you run recipe install ripgrep, the engine loads this saved ctx and passes it to is_acquired(ctx). Since extract_dir is already populated, the check succeeds and acquire(ctx) is skipped entirely.
Built-in Constants
These read-only values are available in all recipe scripts:
| Constant | Example Value | Description |
|---|---|---|
| PREFIX | /usr/local | Installation prefix (or $OUT if set) |
| BUILD_DIR | /tmp/recipe-xxxx | Temporary build directory |
| ARCH | x86_64 | Target architecture |
| NPROC | 8 | Number of CPU cores |
| RPM_PATH | /var/cache/rpms | RPM repository path (from env) |
Lifecycle
When you run recipe install , the CLI executes these phases in order. Each phase has a check function that's called first - if it succeeds, the action is skipped:
- Acquire -
is_acquired(ctx)→acquire(ctx)- Get source materials - Build -
is_built(ctx)→build(ctx)- Compile or transform (optional) - Install -
is_installed(ctx)→install(ctx)- Copy files to PREFIX
This check-then-act pattern enables efficient caching. If sources are already downloaded, acquire is skipped. If already built, build is skipped.
Required Functions
| Function | Purpose |
|---|---|
| is_acquired(ctx) | Check if source materials exist, return ctx with paths or throw |
| acquire(ctx) | Download/copy source materials, return ctx with source_path |
| is_installed(ctx) | Check if package is installed, return ctx or throw |
| install(ctx) | Install files to PREFIX, return ctx |
Optional Functions
| Function | Purpose |
|---|---|
| is_built(ctx) | Check if already built, return ctx or throw |
| build(ctx) | Compile or transform sources, return ctx |
| cleanup(ctx) | Remove build artifacts, preserve installed files |
| check_update() | Check for updates (return version string or ()) |
Examples
GitHub Release Binary
Download a pre-built release using github_download_release():
let ctx = #{
name: "ripgrep",
version: "14.1.1",
repo: "BurntSushi/ripgrep",
description: "Fast line-oriented search tool",
extract_dir: "",
};
fn is_acquired(ctx) {
let dir = join_path(BUILD_DIR, "ripgrep-" + ctx.version + "-" + ARCH + "-unknown-linux-musl");
if is_file(join_path(dir, "rg")) {
ctx.extract_dir = dir;
return ctx;
}
throw "not acquired";
}
fn acquire(ctx) {
mkdir(BUILD_DIR);
let pattern = "ripgrep-*-" + ARCH + "-unknown-linux-musl.tar.gz";
let archive = github_download_release(ctx.repo, ctx.version, pattern);
extract(archive, BUILD_DIR);
ctx.extract_dir = join_path(BUILD_DIR, "ripgrep-" + ctx.version + "-" + ARCH + "-unknown-linux-musl");
ctx
}
fn is_installed(ctx) {
if is_file(join_path(PREFIX, "bin/rg")) {
return ctx;
}
throw "not installed";
}
fn install(ctx) {
mkdir(join_path(PREFIX, "bin"));
shell("install -Dm755 " + join_path(ctx.extract_dir, "rg") + " " + join_path(PREFIX, "bin/rg"));
shell("install -Dm644 " + join_path(ctx.extract_dir, "doc/rg.1") + " " + join_path(PREFIX, "share/man/man1/rg.1"));
ctx
} See Helper Functions for github_download_release(), extract(), etc.
Source Build
Build from source using configure/make:
let ctx = #{
name: "bash",
version: "5.2.37",
description: "GNU Bourne-Again Shell",
source_dir: "",
};
fn is_acquired(ctx) {
let src = join_path(BUILD_DIR, ctx.name + "-" + ctx.version);
if is_file(join_path(src, "configure")) {
ctx.source_dir = src;
return ctx;
}
throw "source not acquired";
}
fn acquire(ctx) {
mkdir(BUILD_DIR);
let url = "https://ftp.gnu.org/gnu/bash/bash-" + ctx.version + ".tar.gz";
let archive = download(url, join_path(BUILD_DIR, "bash.tar.gz"));
extract(archive, BUILD_DIR);
ctx.source_dir = join_path(BUILD_DIR, ctx.name + "-" + ctx.version);
ctx
}
fn is_built(ctx) {
if is_file(join_path(ctx.source_dir, "bash")) {
return ctx;
}
throw "not built";
}
fn build(ctx) {
shell_in(ctx.source_dir, "./configure --prefix=" + PREFIX);
shell_in(ctx.source_dir, "make -j" + NPROC);
ctx
}
fn is_installed(ctx) {
if is_file(join_path(PREFIX, "bin/bash")) {
return ctx;
}
throw "not installed";
}
fn install(ctx) {
shell_in(ctx.source_dir, "make install DESTDIR=" + PREFIX);
ctx
}Git Clone Source
Clone source from git using git_clone_depth():
let ctx = #{
name: "neovim",
version: "0.10.0",
repo: "https://github.com/neovim/neovim.git",
description: "Vim-fork focused on extensibility",
source_dir: "",
};
fn is_acquired(ctx) {
let src = join_path(BUILD_DIR, "neovim");
if is_file(join_path(src, "CMakeLists.txt")) {
ctx.source_dir = src;
return ctx;
}
throw "source not acquired";
}
fn acquire(ctx) {
mkdir(BUILD_DIR);
let src = git_clone_depth(ctx.repo, join_path(BUILD_DIR, "neovim"), 1);
shell_in(src, "git checkout v" + ctx.version);
ctx.source_dir = src;
ctx
}
fn is_built(ctx) {
if is_file(join_path(ctx.source_dir, "build/bin/nvim")) {
return ctx;
}
throw "not built";
}
fn build(ctx) {
shell_in(ctx.source_dir, "make CMAKE_BUILD_TYPE=Release");
ctx
}
fn is_installed(ctx) {
if is_file(join_path(PREFIX, "bin/nvim")) {
return ctx;
}
throw "not installed";
}
fn install(ctx) {
shell_in(ctx.source_dir, "make install CMAKE_INSTALL_PREFIX=" + PREFIX);
ctx
}With Update Checking
Implement check_update() to enable recipe update:
fn check_update() {
let latest = github_latest_release("BurntSushi/ripgrep");
if latest != ctx.version {
latest // Return new version
} else {
() // No update available
}
} When an update is found, recipe upgrade will remove the old version and install the new one.
Package Resolution
When you run recipe install , the CLI looks for recipes in this order:
- If
contains/or ends with.rhai: treat as explicit path / .rhai (subdirectory style)/ / .rhai
The default recipes directory is ~/.local/share/recipe/recipes/. Override with --recipes-path or RECIPE_PATH environment variable.
See Also
- CLI Reference - Commands for installing and managing packages
- Helper Functions - All available functions for recipes
- Path Helpers -
join_path(),dirname(),basename()