hatchling-runtime 0.1.2

Personal AI agents you actually own, in kernel-isolated cells that never hold your keys: installs the `hatchling` command
Documentation
//! `hatchling-runtime`: the `hatchling` command.
//!
//! One binary for the whole runtime: `hatchling broker` runs the broker
//! and `hatchling agent` runs the agent, each with exactly the command
//! line of `hatchling-broker` and `hatchling-agent`. Install it with
//! `cargo install hatchling-runtime`.
//!
//! Two invocations don't name a subcommand, because the broker makes
//! them itself:
//! - The broker starts its spawner helpers (`skill-cell`, `agent-run`)
//!   by re-running its own executable, which is `hatchling` here.
//! - A cell runs its program as `/agent`, so `hatchling` started under
//!   that name is the agent. Point `HATCHLING_AGENT_BIN` at `hatchling`
//!   and the one binary serves both sides.
//!
//! The routing is [`route`], a pure function, so it's tested without
//! starting anything.

use std::path::Path;

use hatchling_broker::AGENT_RUN_SUBCOMMAND;
use hatchling_broker::skill_run::SKILL_CELL_SUBCOMMAND;
use hatchling_isolation::PROGRAM;

/// Usage, printed for `--help` on stdout and for a usage error on stderr.
pub const USAGE: &str = "\
hatchling: personal AI agents in kernel-isolated cells

Usage:
  hatchling broker [subcommand]   serve agents, or run one operator subcommand
  hatchling agent [run \"task\"]    run the agent (the broker starts it inside a cell)
  hatchling --help                print this help
  hatchling --version             print the version

Broker subcommands (run instead of serving):
  migrate-vault, rekey-vault <new-key-file>, vault-keyring <action>,
  redactions <action>, vault-audit

The broker is configured by HATCHLING_* environment variables; see the
README's quickstart. Set HATCHLING_AGENT_BIN to this binary's path to run
the agent from it too.";

/// What one invocation of `hatchling` runs.
#[derive(Debug, PartialEq, Eq)]
pub enum Route<'a> {
    /// The broker's command line, with these arguments.
    Broker(&'a [String]),
    /// The agent's command line, with these arguments.
    Agent(&'a [String]),
    /// `--help`: usage on stdout, exit 0.
    Help,
    /// `--version`: the version on stdout, exit 0.
    Version,
    /// No subcommand, or an unknown one: usage on stderr, exit 64.
    Usage,
}

/// Routes one invocation: `argv0` is the name it was started under, and
/// `args` the arguments after it.
#[must_use]
pub fn route<'a>(argv0: &str, args: &'a [String]) -> Route<'a> {
    if Path::new(argv0).file_name() == Path::new(PROGRAM).file_name() {
        return Route::Agent(args);
    }
    let rest = args.get(1..).unwrap_or_default();
    match args.first().map(String::as_str) {
        Some("broker") => Route::Broker(rest),
        Some("agent") => Route::Agent(rest),
        Some(SKILL_CELL_SUBCOMMAND | AGENT_RUN_SUBCOMMAND) => Route::Broker(args),
        Some("--help" | "-h" | "help") => Route::Help,
        Some("--version" | "-V") => Route::Version,
        Some(_) | None => Route::Usage,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn args(list: &[&str]) -> Vec<String> {
        list.iter().map(|arg| (*arg).to_owned()).collect()
    }

    /// Everything after the subcommand.
    fn tail(given: &[String]) -> &[String] {
        given.get(1..).unwrap_or_default()
    }

    #[test]
    fn subcommands_route_to_their_command_line_without_the_subcommand() {
        let given = args(&["broker", "rekey-vault", "/new.key"]);
        assert_eq!(route("hatchling", &given), Route::Broker(tail(&given)));
        let given = args(&["agent", "run", "Say hi."]);
        assert_eq!(
            route("/usr/local/bin/hatchling", &given),
            Route::Agent(tail(&given))
        );
        let given = args(&["broker"]);
        assert_eq!(route("hatchling", &given), Route::Broker(&[]));
    }

    #[test]
    fn the_brokers_spawner_helpers_reach_the_broker_with_their_subcommand() {
        for helper in [SKILL_CELL_SUBCOMMAND, AGENT_RUN_SUBCOMMAND] {
            let given = args(&[helper, "/run/spec"]);
            assert_eq!(
                route("/usr/local/bin/hatchling", &given),
                Route::Broker(&given)
            );
        }
    }

    #[test]
    fn started_as_the_cell_program_it_is_the_agent_with_every_argument() {
        let given = args(&["run", "Say hi."]);
        assert_eq!(route(PROGRAM, &given), Route::Agent(&given));
        assert_eq!(route(PROGRAM, &[]), Route::Agent(&[]));
        // Even an argument that names a subcommand stays the agent's.
        let given = args(&["broker"]);
        assert_eq!(route(PROGRAM, &given), Route::Agent(&given));
    }

    #[test]
    fn help_and_version_have_their_short_and_long_forms() {
        for flag in ["--help", "-h", "help"] {
            assert_eq!(route("hatchling", &args(&[flag])), Route::Help);
        }
        for flag in ["--version", "-V"] {
            assert_eq!(route("hatchling", &args(&[flag])), Route::Version);
        }
    }

    #[test]
    fn nothing_or_an_unknown_subcommand_is_a_usage_error() {
        assert_eq!(route("hatchling", &[]), Route::Usage);
        assert_eq!(route("hatchling", &args(&["serve"])), Route::Usage);
        assert_eq!(route("hatchling", &args(&["migrate-vault"])), Route::Usage);
    }

    #[test]
    fn a_name_that_only_ends_in_agent_is_not_the_cell_program() {
        assert_eq!(
            route("/usr/bin/hatchling-agent", &args(&["broker"])),
            Route::Broker(&[])
        );
        assert_eq!(route("secret-agent", &[]), Route::Usage);
    }
}