mod cli;
use std::process::ExitCode;
use cli::CliError;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("{}", err.message());
ExitCode::from(err.exit_code())
}
}
}
fn run() -> Result<(), CliError> {
let mut raw_args: Vec<String> = std::env::args().skip(1).collect();
let output = parse_global_output(&mut raw_args)?;
let mut args = raw_args.into_iter();
let command = args.next();
if output.is_some() && command.as_deref() != Some("host-hook") {
return Err(CliError::Usage(
"--output is only supported for `host-hook`".into(),
));
}
match command.as_deref() {
None | Some("help") | Some("--help") | Some("-h") => {
print_help();
Ok(())
}
Some("version") | Some("--version") | Some("-V") => {
println!("{}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("events") => {
let json = serde_json::to_string_pretty(&lifeloop::lifecycle_event_kinds()).map_err(
|err| CliError::Input(format!("failed to serialize event vocabulary: {err}")),
)?;
println!("{json}");
Ok(())
}
Some("envelope") => run_envelope(args),
Some("event") => cli::event::run(args),
Some("manifest") => cli::manifest::run(args),
Some("asset") => cli::asset::run(args),
Some("telemetry") => cli::telemetry::run(args),
Some("receipt") => cli::receipt::run(args),
Some("conformance") => cli::conformance::run(args),
Some("host-hook") => cli::host_hook::run(args, output.as_deref()),
Some(command) => Err(CliError::Usage(format!("unknown command: {command}"))),
}
}
fn parse_global_output(args: &mut Vec<String>) -> Result<Option<String>, CliError> {
if args.first().map(String::as_str) == Some("--output") {
if args.len() < 2 {
return Err(CliError::Usage("flag `--output` requires a value".into()));
}
let value = args.remove(1);
args.remove(0);
return Ok(Some(value));
}
if let Some(first) = args.first()
&& let Some(value) = first.strip_prefix("--output=")
{
let value = value.to_string();
args.remove(0);
return Ok(Some(value));
}
Ok(None)
}
fn run_envelope<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
let action = args.next().ok_or_else(|| {
CliError::Usage("envelope requires a subcommand: validate | echo".to_string())
})?;
let kind = args.next().ok_or_else(|| {
CliError::Usage(format!(
"envelope {action} requires a kind: request | response"
))
})?;
if args.next().is_some() {
return Err(CliError::Usage(format!(
"envelope {action} {kind}: unexpected extra argument"
)));
}
match (action.as_str(), kind.as_str()) {
("validate", "request") => validate::<lifeloop::CallbackRequest>("request"),
("validate", "response") => validate::<lifeloop::CallbackResponse>("response"),
("echo", "request") => echo::<lifeloop::CallbackRequest>(),
("echo", "response") => echo::<lifeloop::CallbackResponse>(),
("validate" | "echo", other) => Err(CliError::Usage(format!(
"envelope {action}: unknown kind `{other}` (expected: request | response)"
))),
_ => Err(CliError::Usage(format!(
"envelope: unknown subcommand `{action}` (expected: validate | echo)"
))),
}
}
trait Validatable: serde::de::DeserializeOwned + serde::Serialize {
fn validate(&self) -> Result<(), lifeloop::ValidationError>;
}
impl Validatable for lifeloop::CallbackRequest {
fn validate(&self) -> Result<(), lifeloop::ValidationError> {
self.validate()
}
}
impl Validatable for lifeloop::CallbackResponse {
fn validate(&self) -> Result<(), lifeloop::ValidationError> {
self.validate()
}
}
fn validate<T: Validatable>(label: &str) -> Result<(), CliError> {
let value = parse_stdin::<T>(label)?;
value
.validate()
.map_err(|err| CliError::Validation(format!("{label} failed validation: {err}")))?;
println!("ok");
Ok(())
}
fn echo<T: Validatable>() -> Result<(), CliError> {
let value = parse_stdin::<T>("envelope")?;
let json = serde_json::to_string_pretty(&value)
.map_err(|err| CliError::Input(format!("failed to serialize envelope: {err}")))?;
println!("{json}");
Ok(())
}
fn parse_stdin<T: serde::de::DeserializeOwned>(label: &str) -> Result<T, CliError> {
let buf = cli::read_stdin(&format!("{label} envelope"))?;
serde_json::from_str::<T>(&buf)
.map_err(|err| CliError::Input(format!("{label} envelope: invalid JSON: {err}")))
}
fn print_help() {
println!("lifeloop");
println!();
println!("Provider-neutral lifecycle abstraction for AI harnesses.");
println!();
println!("Commands:");
println!(" events Print the lifecycle event vocabulary as JSON");
println!(" envelope validate <kind> Validate a JSON envelope read from stdin");
println!(" kind: request | response");
println!(" prints `ok` on success; errors go to stderr");
println!(" envelope echo <kind> Parse and pretty-print an envelope from stdin");
println!(" kind: request | response");
println!(" event invoke Run validate -> negotiate -> callback -> receipt");
println!(" flags: --client-cmd <path> [--client-arg <a>]...");
println!(" [--timeout-ms <ms>] [--client-id <id>]");
println!(" [--receipt-id <id>] [--at-epoch-s <n>]");
println!(" [--in-process]");
println!(" manifest list List registered adapters as JSON");
println!(" manifest show <id> Print one AdapterManifest as JSON");
println!(" manifest inspect <id>@<ver> Print a manifest pinned to a version");
println!(" asset preview --host <h> --mode <m> Print rendered host assets as JSON");
println!(" flags: [--profile <id>]");
println!(" (read-only; callers write files themselves —");
println!(" see docs/decisions/asset-apply-boundary.md)");
println!(" telemetry snapshot --host <id> Read host telemetry, emit PressureObservation");
println!(" receipt emit Synthesize a LifecycleReceipt from a wire envelope");
println!(" receipt show Validate and pretty-print a receipt from stdin");
println!(" conformance run Walk tests/conformance/ fixtures and emit JSONL");
println!(" flags: [--root <dir>] [--summary]");
println!(" --output hook-protocol host-hook Run a synchronous host-hook broker");
println!(" flags: --host <h> --hook <name> [--client-cmd <cmd>]");
println!(" version Print the Lifeloop version");
println!();
println!("Exit codes: 0=ok 1=validation error 2=usage error 3=input/IO error");
println!();
println!("Spec: docs/specs/lifecycle-contract/body.md");
}