Execra: one CLI runtime for headless and GUI callers
rScoop puts a GUI around Scoop. The difficult code sits between them: it runs scoop install, scoop doctor, or scoop update, then turns process output into state the interface can use.
Every operation needs process startup, piped output, cancellation, and a verdict. An exit code of 0 may still come with a warning three screens back. Closing the window cannot block the work, and Cancel has to stop child processes too.
I pulled that code into a crate called Execra. rScoop v1.8.1 uses it for installs, updates, cleanup, and VirusTotal scans.
Execra is a typed job runtime for Rust applications that wrap command-line programs. It runs the command, publishes structured events, tracks the job, and stops the process tree when asked.
Moving rScoop over deleted its custom PowerShell wrapper and update helper. The backend now tracks Execra job IDs. Cancellation reaches the process, warnings keep their own status, and fewer bits of UI state can go stale.
The crate targets Rust backends, especially Tauri apps, that put a GUI around a CLI. Its tauri feature provides app.execra(), event forwarding, cancellation, and recent-job access.
Runtime and job handles
A Runtime accepts a Command and returns a JobHandle.
use execra::{Command, Runtime};
let rt = Runtime::new();
let outcome = rt
.spawn(Command::new("scoop").args(["install", "git"]))?
.await;
outcome.into_result()?;
The handle can be awaited and observed.
let handle = rt.spawn(cmd)?;
let id = handle.id();
let mut events = handle.subscribe();
tokio::spawn(async move {
while let Some(event) = events.next().await {
// render, log, or forward it
let _ = event;
}
});
let outcome = handle.await;
A single spawn call serves a headless caller waiting for the verdict and a UI subscribing to live events. Both paths use the same runner and job handle.
rScoop stores the Execra job ID on each running operation. Cancelling an install, update, cleanup, or scan cancels that job, so the interface does not maintain a separate idea of whether the process is still running.
Commands are argument lists
Command::new("git").args(["status", "--short"]) stores a program and its arguments. It does not parse a shell string or guess at quoting rules.
Shell commands use explicit constructors:
Command::powershell("Get-ChildItem");
Command::cmd("dir");
Command::sh("ls -la");
Command::shell("echo hello");
Most failures in this layer come from quoting, working directories, hidden windows, or timeouts. Keeping program and arguments separate removes one source of ambiguity.
Cancelling a process tree
scoop install git can spawn download and extraction helpers. Killing only the Scoop process leaves those children running.
Before Execra, Cancel could update the interface while network and disk work continued in a child process. Users had no way to tell that from the operation state.
On Unix, Execra creates a process group with setpgid and signals the group. On Windows, it assigns the child to a Win32 Job Object. Both paths stop the full subtree.
Process-tree cancellation now sits behind one method. In rScoop, the Cancel button calls that method with the operation’s job ID.
Typed events
The runtime emits typed events:
JobCreatedJobStarted- output lines
- phase, progress, warning, known-error, finding, prompt, and summary events
ExitedFinalizedCancelled
Raw output remains available for logs. The UI uses typed events to show download progress, diagnostic recommendations, and actions attached to known errors.
rScoop v1.8.1 maps results to success, warning, or error. For example, Scoop’s “Running process detected, skip updating” output becomes a warning. The operation bar, modal, history, background toast, and log footer all receive the same status.
Execra hosts interpreters supplied by the caller, because the caller knows what the CLI output means.
pub trait Interpreter: Send {
fn on_line(&mut self, ctx: &Context, line: &Line) -> Vec<InterpreterEvent>;
fn on_exit(&mut self, ctx: &Context, exit: &ExitCode) -> Vec<InterpreterEvent>;
}
An interpreter receives decoded output lines and the final exit state, keeps its own parsing state, and emits metadata. Runtime state and Finalized stay under Execra’s control. An interpreter error is reported as metadata failure while the process continues.
Diagnostic findings
scoop doctor can run successfully and still find problems. Its exit code says whether the diagnostic ran; its output describes what the user should fix.
Execra stores those findings separately from the verdict. A finding can be info, recommendation, warning, or error, with an optional command, link, or instruction.
The GUI can render a command as a button, a link as an anchor, and an instruction as copyable text. Findings remain attached when the final Outcome becomes Succeeded, Failed, or Cancelled.
VirusTotal scans use findings for missing API keys and detections. The Execra operation path now maps their exit codes, warnings, and log output in one place.
Draining output before Finalized
Execra emits Exited and Finalized for different events.
Process exit and pipe EOF are independent OS events. Calling the final callback as soon as wait() returns can classify a job before stdout or stderr is fully drained. The final line often contains the error or summary needed for the verdict.
Execra waits for both pipes and the process. It emits Exited, calls the interpreter’s on_exit, computes the Outcome, and then emits Finalized.
The event order is fixed:
JobCreatedJobStarted- output and interpreter events
ExitedInterpreter::on_exitFinalized
Writing the order down also gives tests and callers one sequence to expect.
Opt-in persistence
Runtime::new() keeps everything in memory and does not write to disk.
let rt = execra::Runtime::new();
History, raw logs, concurrency limits, retention, and grace-period tuning are configured through the builder:
let rt = execra::Runtime::builder()
.history("./jobs.sqlite")
.log_dir("./raw")
.raw_output(execra::RawOutputPolicy::Persist)
.max_concurrent(4)
.build()?;
I want a small script to stay in memory unless its author asks for a database or log directory.
Tauri plugin
The tauri feature exposes Execra as a plugin:
tauri::Builder::default()
.plugin(execra::tauri::init())
.invoke_handler(tauri::generate_handler![run_tool, cancel, history])
.run(tauri::generate_context!())
.unwrap();
Tauri commands access the runtime through app.execra():
use execra::tauri::ExecraExt;
#[tauri::command]
fn run_tool(app: tauri::AppHandle, args: Vec<String>) -> Result<execra::JobId, String> {
app.execra()
.task(execra::Command::new("scrcpy").args(args))
.channel("scrcpy:log")
.spawn_tracked()
.map_err(|e| e.to_string())
}
#[tauri::command]
fn cancel(app: tauri::AppHandle, id: execra::JobId) -> Result<(), String> {
app.execra().cancel(id).map_err(|e| e.to_string())
}
.channel(name) serializes the typed Event enum onto one Tauri event channel. Frontend code matches on kind.
TaskBuilder also has typed backend hooks such as on_created, on_output, on_interpreter_error, and on_finalized. The hooks observe the event stream used by the frontend.
The plugin accepts a prebuilt runtime when an app needs persisted history:
tauri::Builder::default()
.plugin(execra::tauri::init_with(
execra::Runtime::builder()
.history("./jobs.sqlite")
.max_concurrent(2)
.build()
.expect("open Execra runtime"),
));
In rScoop, frontend code receives the event stream, backend hooks update package state, and one runtime owns the processes for every screen.
After installs, updates, and scans, rScoop refreshes installed packages, holds, updates, and versioned package state together. It no longer falls back to a heavier cold-start reload. Warm searches reuse parsed manifests and binary aliases, while bucket changes invalidate the cache for the configured Scoop path.
Scope
Execra runs local commands and reports their jobs. Shell parsing, task graphs, distributed execution, and tool-specific interpreters stay with the caller.
Callers decide what to run, chain jobs with .await, and write interpreters for the CLIs they understand.
Keeping domain rules outside the runtime lets an application interpret its own tools without adding those rules to Execra.
Changes in rScoop v1.8.1
The migration removed rScoop’s old PowerShell command wrapper and update helper. Installs, updates, cleanup, bucket work, automatic package updates, and VirusTotal scans now go through one operation path, so their state is no longer spread across separate helpers.
System Doctor’s outdated-cache action uses the version-aware cleanup path shared with automatic cleanup. It removes stale downloads while leaving current cache files alone. The operation log shows deleted-file counts and per-file failures.
Operation results carry success, warning, or error status. Scoop’s “running process detected, skip updating” case becomes a warning, as do the relevant VirusTotal results. The operation bar, modal, history, background toast, and log footer render the same status.
Warm search caches parsed manifests and binary aliases. Bucket changes invalidate the configured Scoop path. After installs and updates, rScoop refreshes installed packages, holds, updates, and versioned package state without a cold-start reload.
Those behavior changes justified publishing Execra as its own crate. There is less custom process code, cancellation stops the work, and warning state reaches every surface that displays an operation.
Why I split it out
Desktop wrappers for Git, ffmpeg, and package managers all need similar process code. They spawn a command, parse its output, decide whether it worked, stop its children on Cancel, and sometimes preserve enough history to survive a restart.
Process runtimes are easy to underestimate. Mistakes show up as stuck children, lost output, stale progress, or a success message attached to a warning.
I split Execra out because I was tired of maintaining another private process runtime inside rScoop. Version 1.8.1 is the first production user. If another Rust or Tauri app can delete its own copy of this code, the crate has done enough.