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 run horus mycommand)
  • Use clap or 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:

VariableValueDescription
HORUS_PLUGIN1Always set when running as a plugin
HORUS_VERSIONe.g., 0.2.2Version 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:

  1. Project plugins.lock.horus/plugins.lock in the current project
  2. Global plugins.lock~/.config/horus/plugins.lock
  3. Project bin directory.horus/bin/horus-*
  4. Global bin directory~/.config/horus/bin/horus-* ($XDG_CONFIG_HOME/horus/bin; ~/Library/Application Support/horus/bin on macOS)
  5. 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 verify to check all plugin integrity (or horus plugin verify <name> for one; add --json for 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