Creating CLI Plugins
A CLI plugin is a standalone binary that adds a subcommand to horus. When a user runs horus mycommand, HORUS discovers your plugin binary and executes it, passing through all arguments.
Zero-Config Convention
Any Rust package named horus-* with a [[bin]] target is automatically detected as a plugin. No extra configuration required.
Example: A package named horus-sim3d with [[bin]] name = "sim3d" automatically provides the horus sim3d command.
Step-by-Step Guide
Step 1: Create a New Rust Project
cargo new horus-mycommand
cd horus-mycommand
Step 2: Set Up Cargo.toml
[package]
name = "horus-mycommand"
version = "0.1.0"
edition = "2021"
description = "My custom HORUS plugin"
[[bin]]
name = "mycommand"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
The key points:
- Package name starts with
horus- - The
[[bin]]name defines the subcommand (users will runhorus mycommand) - Use
clapor similar for argument parsing
Step 3: Implement the Plugin
// src/main.rs
use clap::Parser;
#[derive(Parser)]
#[command(name = "mycommand", about = "My custom HORUS command")]
struct Cli {
/// Target to operate on
#[arg(short, long)]
target: Option<String>,
/// Enable verbose output
#[arg(short, long)]
verbose: bool,
}
fn main() {
let cli = Cli::parse();
// Check if running as a HORUS plugin
if std::env::var("HORUS_PLUGIN").is_ok() {
let horus_version = std::env::var("HORUS_VERSION")
.unwrap_or_else(|_| "unknown".to_string());
if cli.verbose {
eprintln!("Running as HORUS plugin (HORUS v{})", horus_version);
}
}
// Your plugin logic here
match cli.target {
Some(target) => println!("Operating on: {}", target),
None => println!("No target specified. Use --help for usage."),
}
}
Step 4: Build and Test Locally
# Build the plugin
cargo build --release
# Test it standalone
./target/release/mycommand --help
# Test it as a HORUS plugin (simulating the environment variables only --
# the real sandbox described below is not reproduced here)
HORUS_PLUGIN=1 HORUS_VERSION=0.2.2 ./target/release/mycommand --target foo
Step 5: Install Locally
To test with the actual horus CLI, install the binary where HORUS can find it:
# Option A: Copy to global plugin bin directory
mkdir -p ~/.config/horus/bin
cp target/release/mycommand ~/.config/horus/bin/horus-mycommand
# Option B: Install via horus from the registry (once published)
horus install --plugin horus-mycommand
On macOS the global plugin bin directory is ~/Library/Application Support/horus/bin; on either platform it honours $XDG_CONFIG_HOME when that is set.
Note the file name: HORUS resolves horus mycommand by looking for a binary called exactly horus-mycommand, which is why the cp above renames it. A cargo installed mycommand sitting on your $PATH under its [[bin]] name is not found.
horus install resolves package names from the registry — it has no local-path or --local mode, so Option A is the way to test an unpublished build.
Now horus mycommand --help should work.
Environment Variables
HORUS sets these environment variables when executing plugins:
| Variable | Value | Description |
|---|---|---|
HORUS_PLUGIN | 1 | Always set when running as a plugin |
HORUS_VERSION | e.g., 0.2.2 | Version of the HORUS CLI |
Your plugin inherits the user's stdin, stdout, and stderr, so interactive prompts and colored output work normally.
Sandbox
On Linux, foreground plugins run under a sandbox applied between fork and exec: RLIMIT_CPU 300 cumulative CPU-seconds (hard limit equal to soft, so overrun is an uncatchable SIGKILL), RLIMIT_FSIZE 256 MiB, RLIMIT_NOFILE 64, every inherited file descriptor above stderr closed, and PR_SET_NO_NEW_PRIVS.
A seccomp-BPF filter returns EPERM for the socket syscalls (socket, connect, bind, listen, accept, accept4, sendmsg, sendto, recvmsg, recvfrom, getsockopt, setsockopt) — so a CLI plugin cannot open network connections — and kills the process outright for ptrace, chroot, pivot_root, mount, umount2, setuid, setgid, setreuid, setregid, setresuid, setresgid, capset, mknod, mknodat, and perf_event_open.
Syscall filtering applies only on x86-64 Linux; the resource limits and file-descriptor closure apply on all Linux architectures. Long-lived plugins — the ones HORUS spawns in the background instead of running in the foreground and waiting for them to exit — get only the file-descriptor closure and PR_SET_NO_NEW_PRIVS: no resource caps and no network restriction.
Plugin Discovery
HORUS discovers plugin binaries in this order:
- Project
plugins.lock—.horus/plugins.lockin the current project - Global
plugins.lock—~/.config/horus/plugins.lock - Project bin directory —
.horus/bin/horus-* - Global bin directory —
~/.config/horus/bin/horus-*($XDG_CONFIG_HOME/horus/bin;~/Library/Application Support/horus/binon macOS) - System PATH — Any
horus-*binary in$PATH
The first match wins, in exactly this order — so a project plugins.lock entry beats everything, but a globally registered plugin (2) still beats a project bin-directory binary (3). A project registry with inherit_global = false skips the global registry entirely.
Plugins found in the project .horus/ (entries 1 and 3) are treated as attacker-controlled — they are part of any cloned checkout — and are refused unless their content hash is recorded in the machine-local trust store. Run horus plugin trust <command> once to allow one (horus plugin trusted lists them, horus plugin untrust <command> revokes).
Plugin Metadata
Separately from the execution lookup above, horus search and horus info build a catalog of available plugins by scanning for packages named horus-*. That scan reads your Cargo.toml [package] section (name, version, description) and auto-detects the plugin category from the name (e.g., horus-realsense → Camera, horus-rplidar → LiDAR, horus-sim3d → Simulation).
Security
When a plugin is installed through the registry, HORUS records a SHA-256 checksum of the binary. Before each execution, the checksum is verified:
- If the binary has been modified, HORUS refuses to run it
- Run
horus plugin verifyto check all plugin integrity (orhorus plugin verify <name>for one; add--jsonfor machine-readable output) - Reinstall a plugin with
horus install --plugin <name>if verification fails
Example: A Complete Plugin
Here is a minimal but complete plugin that queries HORUS topic statistics:
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "topic-stats", about = "Show topic statistics summary")]
struct Cli {
/// Shared memory topics directory
#[arg(long, default_value = "/dev/shm/horus_default/topics")]
shm_dir: PathBuf,
/// Output as JSON
#[arg(long)]
json: bool,
}
/// The `.meta` sidecar HORUS writes into `topics/` for every live topic.
#[derive(serde::Serialize, serde::Deserialize)]
struct TopicMeta {
name: String,
size: usize,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if !cli.shm_dir.exists() {
eprintln!("No HORUS topics found at {}", cli.shm_dir.display());
std::process::exit(1);
}
// Scan the sidecars, not the region files: sidecars are flat even for
// namespaced topics, and each one carries the real topic name.
let mut topics = Vec::new();
for entry in std::fs::read_dir(&cli.shm_dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("meta") {
continue;
}
let meta: TopicMeta = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
topics.push((meta.name, meta.size));
}
topics.sort();
if cli.json {
println!("{}", serde_json::to_string_pretty(&topics)?);
} else {
println!("Found {} topics:", topics.len());
for (name, size) in &topics {
println!(" {} ({} bytes)", name, size);
}
}
Ok(())
}
Topic backing files live in /dev/shm/horus_<namespace>/topics/, where the namespace defaults to the literal default and is overridden with $HORUS_NAMESPACE — pass --shm-dir to inspect another namespace.
The example enumerates the .meta sidecars rather than the region files themselves, because a topic name may contain /. A namespaced topic such as lidar/scan puts its region at topics/lidar/scan — nested one level down — but its sidecar stays flat in topics/ with the separator rewritten, as lidar_scan.meta. Reading the sidecars therefore reaches every topic from a single non-recursive scan, and the name field inside each one gives back the original lidar/scan. A sidecar is written when the region is created and removed when its owner releases it, so what you get is the set of live topics.
Cargo.toml:
[package]
name = "horus-topic-stats"
version = "0.1.0"
edition = "2021"
description = "Show HORUS topic statistics"
[[bin]]
name = "topic-stats"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
After building and installing, users run:
horus topic-stats
horus topic-stats --json
Next Steps
- Publishing Plugins — Publish your plugin to the HORUS registry
- Managing Plugins — Install, enable/disable, and verify plugins