mod acp;
mod agent;
mod attach;
mod bg;
mod config;
mod doctor;
mod gc;
mod keysource;
mod session_server;
mod setup;
mod shell_hooks;
mod spawn;
mod structured;
mod tui;
mod watch;
const ZSH_PLUGIN: &str = r#"# hotl zsh plugin — generated by `hotl init zsh`
# A command line starting with `: ` becomes an agent prompt (hotl -p).
# `@[path]` tokens in that prompt are expanded to the file's contents by hotl.
hotl-accept-line() {
if [[ $BUFFER == ':'\ * ]]; then
local prompt_text=${BUFFER#: }
BUFFER="hotl -p ${(qq)prompt_text}"
fi
zle .accept-line
}
zle -N accept-line hotl-accept-line
# OSC-133 shell integration: mark prompt/command/output regions so a terminal
# (and `hotl watch`) can find prompts and command boundaries in the scrollback.
hotl-osc133-precmd() { print -Pn "\e]133;D;$?\a\e]133;A\a" }
hotl-osc133-preexec() { print -Pn "\e]133;C\a" }
autoload -Uz add-zsh-hook
add-zsh-hook precmd hotl-osc133-precmd
add-zsh-hook preexec hotl-osc133-preexec
"#;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("watch") => {
if let Err(e) = watch::watch_main() {
eprintln!("hotl watch: {e}");
std::process::exit(1);
}
}
Some("fleet") => {
eprintln!("hotl fleet (orchestrate) is reserved and not built yet — it arrives after the harness's M4 seams.");
std::process::exit(2);
}
Some("doctor") => std::process::exit(doctor::doctor_main()),
Some("undo") => std::process::exit(agent::undo_main(args[1..].to_vec())),
Some("resume") => {
let mut rest = vec!["--resume".to_string()];
rest.extend(args[1..].iter().cloned());
std::process::exit(block_on(tui::tui_main(rest)));
}
Some("acp") => std::process::exit(block_on(agent::acp_main())),
Some("bg") => std::process::exit(bg::bg_main(bg_prompt(&args))),
Some("attach") => std::process::exit(block_on(attach::attach_main(
args.get(1).map(String::as_str),
))),
Some("serve") => {
let (id, prompt) = parse_serve_args(&args);
std::process::exit(block_on(agent::serve_main(id, prompt)));
}
Some("setup") => {
let force = args.iter().any(|a| a == "--force" || a == "-f");
std::process::exit(setup::setup_main(&agent::config_dir(), force));
}
Some("gc") => std::process::exit(gc::gc_main(&args)),
Some("update") => std::process::exit(update_main(args.get(1).map(String::as_str))),
Some("init") => {
match args.get(1).map(String::as_str) {
Some("zsh") => print!("{}", ZSH_PLUGIN),
_ => {
eprintln!(
"usage: hotl init zsh # then: eval \"$(hotl init zsh)\" in ~/.zshrc"
);
std::process::exit(2);
}
}
}
Some("--help") | Some("-h") | Some("help") => print_help(),
_ => {
if is_headless(&args) {
std::process::exit(block_on(agent::agent_main(args)));
}
std::process::exit(block_on(tui::tui_main(args)));
}
}
}
fn is_headless(args: &[String]) -> bool {
args.iter()
.any(|a| matches!(a.as_str(), "-p" | "--print" | "--json" | "--json-schema"))
}
fn print_help() {
println!(
"hotl — human on the loop\n\n\
USAGE:\n hotl [id-prefix] console TUI (execute); --resume picks a session\n \
hotl -p \"prompt\" headless one-shot (--json for events; --json-schema <f> for validated JSON)\n \
hotl bg [prompt] background a session (detached socket server; attach later)\n \
hotl attach [id] connect to a backgrounded session (bare: list them)\n \
hotl watch tmux agent dashboard (watch)\n \
hotl init zsh print the zsh `:` prefix plugin (eval it in ~/.zshrc)\n \
hotl doctor check provider keys, sandbox, config, session store\n \
hotl setup write default config (safe defaults)\n \
hotl gc [--dry-run] prune old sessions/shadows/blobs per [retention]\n \
hotl resume [id] continue an earlier session in the TUI (bare: pick from a list)\n \
hotl undo restore files to before the agent's last change\n \
hotl fleet reserved (orchestrate)\n\n\
CONFIG: ~/.config/hotl/config.toml (one file: [provider] [context] [behavior]\n \
[retention], plus [[allow]] [[mcp]] [[hook]] [diagnostics]). Env vars override\n \
(HOTL_MODEL, ANTHROPIC_API_KEY / OPENAI_API_KEY, HOTL_OPENAI_BASE_URL,\n \
HOTL_SANDBOX=off). Run `hotl setup` to write a starter.\n\n\
TUI: type to prompt · type mid-turn to steer · y/n answers asks · ? help · ctrl-c quits"
);
}
fn update_main(latest: Option<&str>) -> i32 {
let current = env!("CARGO_PKG_VERSION");
let enforced = if hotl_tools::rules::enforced_build() {
" (security-enforced)"
} else {
""
};
println!("hotl {current}{enforced}");
if let Some(latest) = latest {
if setup::is_newer(current, latest) {
println!("a newer version is available: {latest}");
} else {
println!("up to date (latest: {latest}).");
}
}
println!(
"to update: `cargo install --locked hotl`, or re-run the installer script.\n\
(automated self-update is not wired yet — it needs a signed release feed; MD.)"
);
0
}
fn bg_prompt(args: &[String]) -> Option<&str> {
args.get(1)
.map(String::as_str)
.filter(|a| !a.starts_with('-'))
}
fn parse_serve_args(args: &[String]) -> (String, Option<String>) {
let mut id = None;
let mut prompt = None;
let mut it = args.iter().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"--id" => id = it.next().cloned(),
"--prompt" => prompt = it.next().cloned(),
_ => {}
}
}
(
id.unwrap_or_else(|| format!("bg-{}", std::process::id())),
prompt,
)
}
fn block_on(f: impl std::future::Future<Output = i32>) -> i32 {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime")
.block_on(f)
}
#[cfg(test)]
mod tests {
use super::is_headless;
fn v(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
#[test]
fn headless_flags_route_to_agent() {
assert!(is_headless(&v(&["-p", "hi"])));
assert!(is_headless(&v(&["--json", "-p", "hi"])));
assert!(is_headless(&v(&["-p", "hi", "--json-schema", "s.json"])));
}
#[test]
fn tui_args_do_not() {
assert!(!is_headless(&v(&[])));
assert!(!is_headless(&v(&["01ABC"])));
assert!(!is_headless(&v(&["--resume"])));
}
}