Skip to main content

hatchling_runtime/
lib.rs

1//! `hatchling-runtime`: the `hatchling` command.
2//!
3//! One binary for the whole runtime: `hatchling broker` runs the broker
4//! and `hatchling agent` runs the agent, each with exactly the command
5//! line of `hatchling-broker` and `hatchling-agent`. Install it with
6//! `cargo install hatchling-runtime`.
7//!
8//! Licensed AGPL-3.0-or-later, with the additional terms in
9//! [NOTICE.md](https://github.com/undergroundrap/hatchling/blob/main/NOTICE.md).
10//!
11//! Two invocations don't name a subcommand, because the broker makes
12//! them itself:
13//! - The broker starts its spawner helpers (`skill-cell`, `agent-run`)
14//!   by re-running its own executable, which is `hatchling` here.
15//! - A cell runs its program as `/agent`, so `hatchling` started under
16//!   that name is the agent. Point `HATCHLING_AGENT_BIN` at `hatchling`
17//!   and the one binary serves both sides.
18//!
19//! The routing is [`route`], a pure function, so it's tested without
20//! starting anything.
21
22use std::path::Path;
23
24use hatchling_broker::AGENT_RUN_SUBCOMMAND;
25use hatchling_broker::skill_run::SKILL_CELL_SUBCOMMAND;
26use hatchling_isolation::PROGRAM;
27
28/// Usage, printed for `--help` on stdout and for a usage error on stderr.
29pub const USAGE: &str = "\
30hatchling: personal AI agents in kernel-isolated cells
31
32Usage:
33  hatchling broker [subcommand]   serve agents, or run one operator subcommand
34  hatchling agent [run \"task\"]    run the agent (the broker starts it inside a cell)
35  hatchling --help                print this help
36  hatchling --version             print the version
37
38Broker subcommands (run instead of serving):
39  migrate-vault, rekey-vault <new-key-file>, vault-keyring <action>,
40  redactions <action>, vault-audit
41
42The broker is configured by HATCHLING_* environment variables; see the
43README's quickstart. Set HATCHLING_AGENT_BIN to this binary's path to run
44the agent from it too.";
45
46/// Front-door help includes both command lines' generated variable inventories.
47#[must_use]
48pub fn help() -> String {
49    format!(
50        "{USAGE}\n\n{}\n{}",
51        hatchling_broker::cli::help("hatchling broker"),
52        hatchling_agent::cli::help("hatchling agent")
53    )
54}
55
56/// What one invocation of `hatchling` runs.
57#[derive(Debug, PartialEq, Eq)]
58pub enum Route<'a> {
59    /// The broker's command line, with these arguments.
60    Broker(&'a [String]),
61    /// The agent's command line, with these arguments.
62    Agent(&'a [String]),
63    /// `--help`: usage on stdout, exit 0.
64    Help,
65    /// `--version`: the version on stdout, exit 0.
66    Version,
67    /// No subcommand, or an unknown one: usage on stderr, exit 64.
68    Usage,
69}
70
71/// Routes one invocation: `argv0` is the name it was started under, and
72/// `args` the arguments after it.
73#[must_use]
74pub fn route<'a>(argv0: &str, args: &'a [String]) -> Route<'a> {
75    if Path::new(argv0).file_name() == Path::new(PROGRAM).file_name() {
76        return Route::Agent(args);
77    }
78    let rest = args.get(1..).unwrap_or_default();
79    match args.first().map(String::as_str) {
80        Some("broker") => Route::Broker(rest),
81        Some("agent") => Route::Agent(rest),
82        Some(SKILL_CELL_SUBCOMMAND | AGENT_RUN_SUBCOMMAND) => Route::Broker(args),
83        Some("--help" | "-h" | "help") if args.len() == 1 => Route::Help,
84        Some("--version" | "-V") if args.len() == 1 => Route::Version,
85        Some(_) | None => Route::Usage,
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn args(list: &[&str]) -> Vec<String> {
94        list.iter().map(|arg| (*arg).to_owned()).collect()
95    }
96
97    /// Everything after the subcommand.
98    fn tail(given: &[String]) -> &[String] {
99        given.get(1..).unwrap_or_default()
100    }
101
102    #[test]
103    fn subcommands_route_to_their_command_line_without_the_subcommand() {
104        let given = args(&["broker", "rekey-vault", "/new.key"]);
105        assert_eq!(route("hatchling", &given), Route::Broker(tail(&given)));
106        let given = args(&["agent", "run", "Say hi."]);
107        assert_eq!(
108            route("/usr/local/bin/hatchling", &given),
109            Route::Agent(tail(&given))
110        );
111        let given = args(&["broker"]);
112        assert_eq!(route("hatchling", &given), Route::Broker(&[]));
113    }
114
115    #[test]
116    fn the_brokers_spawner_helpers_reach_the_broker_with_their_subcommand() {
117        for helper in [SKILL_CELL_SUBCOMMAND, AGENT_RUN_SUBCOMMAND] {
118            let given = args(&[helper, "/run/spec"]);
119            assert_eq!(
120                route("/usr/local/bin/hatchling", &given),
121                Route::Broker(&given)
122            );
123        }
124    }
125
126    #[test]
127    fn started_as_the_cell_program_it_is_the_agent_with_every_argument() {
128        let given = args(&["run", "Say hi."]);
129        assert_eq!(route(PROGRAM, &given), Route::Agent(&given));
130        assert_eq!(route(PROGRAM, &[]), Route::Agent(&[]));
131        // Even an argument that names a subcommand stays the agent's.
132        let given = args(&["broker"]);
133        assert_eq!(route(PROGRAM, &given), Route::Agent(&given));
134    }
135
136    #[test]
137    fn help_and_version_have_their_short_and_long_forms() {
138        for flag in ["--help", "-h", "help"] {
139            assert_eq!(route("hatchling", &args(&[flag])), Route::Help);
140        }
141        for flag in ["--version", "-V"] {
142            assert_eq!(route("hatchling", &args(&[flag])), Route::Version);
143        }
144        for flag in ["--help", "-h", "help", "--version", "-V"] {
145            assert_eq!(route("hatchling", &args(&[flag, "extra"])), Route::Usage);
146        }
147        let text = help();
148        assert!(text.contains("Usage: hatchling broker"));
149        assert!(text.contains("Usage: hatchling agent"));
150        assert!(text.contains("HATCHLING_CELL_CGROUP"));
151        assert!(text.contains("HATCHLING_MODEL_ENDPOINT"));
152    }
153
154    #[test]
155    fn nothing_or_an_unknown_subcommand_is_a_usage_error() {
156        assert_eq!(route("hatchling", &[]), Route::Usage);
157        assert_eq!(route("hatchling", &args(&["serve"])), Route::Usage);
158        assert_eq!(route("hatchling", &args(&["migrate-vault"])), Route::Usage);
159    }
160
161    #[test]
162    fn a_name_that_only_ends_in_agent_is_not_the_cell_program() {
163        assert_eq!(
164            route("/usr/bin/hatchling-agent", &args(&["broker"])),
165            Route::Broker(&[])
166        );
167        assert_eq!(route("secret-agent", &[]), Route::Usage);
168    }
169}