use super::{Orient, ShowOptions, WaitOptions, WakeOptions};
use anyhow::{anyhow, bail, Result};
use chrono::{
DateTime, Duration as ChronoDuration, Local, LocalResult, NaiveDateTime, NaiveTime, TimeZone,
};
use clap::{CommandFactory, Parser, Subcommand};
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
#[path = "callback.rs"]
mod callback;
#[derive(Parser)]
#[command(
version = crate::GIT_VERSION,
name = "orient",
about = "Orient the agent with local swarm health, recent messages and goals"
)]
pub struct Cli {
#[arg(long, env = "PILE")]
pile: PathBuf,
#[arg(long, env = "PERSONA")]
persona: Option<String>,
#[arg(long, env = "TRIBLESPACE_KEY")]
key: Option<PathBuf>,
#[arg(
long,
global = true,
env = "TRIBLESPACE_HEALTH_MAX_AGE_SECS",
default_value_t = crate::schemas::swarm_health::DEFAULT_MAX_AGE.as_secs(),
value_name = "SECONDS"
)]
health_max_age: u64,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Show {
#[arg(long, default_value_t = 10)]
message_limit: usize,
#[arg(long, default_value_t = 5)]
doing_limit: usize,
#[arg(long, default_value_t = 5)]
todo_limit: usize,
},
Wake {
#[arg(long, default_value_t = 800_000)]
chars: usize,
#[arg(long, default_value_t = 5)]
doing_limit: usize,
#[arg(long, default_value_t = 5)]
todo_limit: usize,
},
Wait {
#[command(subcommand)]
target: Option<WaitTarget>,
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
},
Daemon {
#[arg(long, value_name = "EXECUTABLE")]
callback: PathBuf,
#[arg(long = "callback-arg", value_name = "ARG")]
callback_args: Vec<OsString>,
#[arg(long, default_value = "30s", value_parser = parse_positive_duration)]
callback_timeout: Duration,
#[arg(long, value_parser = parse_positive_duration)]
run_for: Option<Duration>,
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
},
Poll {
#[arg(long)]
peek: bool,
},
Baseline,
ImportReceipts {
#[arg(long, value_name = "LEGACY_SELECTOR")]
persona: String,
},
}
#[derive(Subcommand, Debug, Clone)]
pub(super) enum WaitTarget {
For {
duration: String,
},
Until {
when: String,
},
}
fn parse_positive_duration(raw: &str) -> Result<Duration, String> {
let value = humantime::parse_duration(raw).map_err(|error| error.to_string())?;
if value.is_zero() {
return Err("duration must be greater than zero".into());
}
Ok(value)
}
pub(super) fn parse_wait_target(target: Option<&WaitTarget>) -> Result<Option<Duration>> {
let Some(target) = target else {
return Ok(None);
};
match target {
WaitTarget::For { duration } => {
let duration = duration.trim();
if duration.is_empty() {
bail!("wait for requires a duration (e.g. 30s, 15m, 9h)");
}
let parsed = humantime::parse_duration(duration)
.map_err(|e| anyhow!("invalid wait duration '{duration}': {e}"))?;
if parsed.is_zero() {
bail!("wait duration must be greater than zero");
}
Ok(Some(parsed))
}
WaitTarget::Until { when } => {
let (parsed, _) = parse_until_spec(when)?;
Ok(Some(parsed))
}
}
}
fn parse_until_spec(raw: &str) -> Result<(Duration, DateTime<Local>)> {
let when = raw.trim();
if when.is_empty() {
bail!("wait until requires a time (e.g. 09:00, 9am, 2026-02-13T09:00:00+01:00)");
}
if let Ok(system_time) = humantime::parse_rfc3339_weak(when) {
let target_local = DateTime::<Local>::from(system_time);
let timeout = system_time
.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO);
return Ok((timeout, target_local));
}
if let Some(local_datetime) = parse_local_datetime_spec(when)? {
let timeout = chrono_duration_to_std(local_datetime.signed_duration_since(Local::now()));
return Ok((timeout, local_datetime));
}
if let Some(local_time) = parse_local_time_spec(when) {
let now = Local::now();
let mut target_naive = now.date_naive().and_time(local_time);
let mut target_local = localize_naive_datetime(target_naive)?;
if target_local <= now {
target_naive += ChronoDuration::days(1);
target_local = localize_naive_datetime(target_naive)?;
}
let timeout = chrono_duration_to_std(target_local.signed_duration_since(now));
return Ok((timeout, target_local));
}
bail!(
"invalid wait until value '{when}'. Use HH:MM, 9am, local datetime, or RFC3339 timestamp"
);
}
fn parse_local_datetime_spec(raw: &str) -> Result<Option<DateTime<Local>>> {
for fmt in [
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%dT%H:%M:%S",
] {
if let Ok(naive) = NaiveDateTime::parse_from_str(raw, fmt) {
return Ok(Some(localize_naive_datetime(naive)?));
}
}
Ok(None)
}
fn parse_local_time_spec(raw: &str) -> Option<NaiveTime> {
for fmt in [
"%H:%M", "%H:%M:%S", "%I:%M %P", "%I:%M%P", "%I %P", "%I%P", "%I:%M %p", "%I:%M%p",
"%I %p", "%I%p",
] {
if let Ok(time) = NaiveTime::parse_from_str(raw, fmt) {
return Some(time);
}
}
None
}
fn localize_naive_datetime(naive: NaiveDateTime) -> Result<DateTime<Local>> {
match Local.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt),
LocalResult::Ambiguous(a, b) => Ok(if a <= b { a } else { b }),
LocalResult::None => bail!(
"local time '{}' does not exist (likely DST transition)",
naive.format("%Y-%m-%d %H:%M:%S")
),
}
}
fn chrono_duration_to_std(duration: ChronoDuration) -> Duration {
if duration <= ChronoDuration::zero() {
Duration::ZERO
} else {
duration.to_std().unwrap_or(Duration::MAX)
}
}
pub fn execute(cli: Cli, out: &mut crate::out::Out<'_>) -> Result<()> {
let Some(command) = cli.command else {
out.line(Cli::command().render_help().to_string())?;
return Ok(());
};
let orient =
Orient::new(cli.pile, cli.key).with_health_max_age(Duration::from_secs(cli.health_max_age));
match command {
Command::Show {
message_limit,
doing_limit,
todo_limit,
} => orient.show(
cli.persona.as_deref(),
&ShowOptions {
message_limit,
doing_limit,
todo_limit,
evaluate_habits: true,
},
out,
),
Command::Wake {
chars,
doing_limit,
todo_limit,
} => orient.wake(
cli.persona.as_deref(),
&WakeOptions {
chars,
doing_limit,
todo_limit,
},
out,
),
Command::Poll { peek } => orient.poll(
cli.persona.as_deref().ok_or_else(|| {
anyhow!("poll requires a persona (pass --persona <label-or-hex> or set $PERSONA)")
})?,
peek,
out,
),
Command::Baseline => {
let persona = cli.persona.as_deref().ok_or_else(|| {
anyhow!(
"baseline requires a persona (pass --persona <label-or-hex> or set $PERSONA)"
)
})?;
let receipt = orient.baseline(persona)?;
out.line(format!(
"Baselined {} current attention event(s) for {persona}.",
receipt.events
))
}
Command::ImportReceipts { persona } => {
let events = orient.import_receipts(&persona)?;
out.line(format!(
"Imported {events} distinct resident legacy event(s) for {persona} into this key's receipt source. Projection maintenance remains separate."
))
}
Command::Wait { target, poll_ms } => orient.wait(
cli.persona.as_deref().ok_or_else(|| {
anyhow!("wait requires a persona (pass --persona <label-or-hex> or set $PERSONA)")
})?,
&WaitOptions {
timeout: parse_wait_target(target.as_ref())?,
poll_interval: Duration::from_millis(poll_ms.max(1)),
},
out,
),
Command::Daemon {
callback,
callback_args,
callback_timeout,
run_for,
poll_ms,
} => {
let persona = cli.persona.as_deref().ok_or_else(|| {
anyhow!("daemon requires a persona (pass --persona <label-or-hex> or set $PERSONA)")
})?;
let mut deliver = |part| match part {
crate::out::Part::Text { text } if text.starts_with("note: ") => {
use std::io::Write;
std::io::stderr().lock().write_all(text.as_bytes())?;
Ok(())
}
crate::out::Part::Text { text } => {
callback::deliver(&callback, &callback_args, callback_timeout, &text)
}
_ => bail!("Orient daemon callback requires a complete text report"),
};
orient.daemon(
persona,
&WaitOptions {
timeout: run_for,
poll_interval: Duration::from_millis(poll_ms.max(1)),
},
&mut crate::out::Out::new(&mut deliver),
)
}
}
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
if cli.command.is_none() {
Cli::command().print_help()?;
println!();
return Ok(());
}
crate::cli::with_output("orient", |out| execute(cli, out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_requires_a_callback() {
assert!(Cli::try_parse_from(["orient", "--pile", "unused.pile", "daemon"]).is_err());
}
#[test]
fn daemon_preserves_literal_arguments_and_parses_bounds() {
let cli = Cli::try_parse_from([
"orient",
"--pile",
"unused.pile",
"--persona",
"agent",
"daemon",
"--callback",
"/path with spaces/deliver",
"--callback-arg=--thread",
"--callback-arg",
"literal $(not-a-command)",
"--callback-timeout",
"2s",
"--run-for",
"1m",
"--poll-ms",
"25",
])
.unwrap();
let Some(Command::Daemon {
callback,
callback_args,
callback_timeout,
run_for,
poll_ms,
}) = cli.command
else {
panic!("expected daemon command");
};
assert_eq!(callback, PathBuf::from("/path with spaces/deliver"));
assert_eq!(
callback_args,
[
OsString::from("--thread"),
OsString::from("literal $(not-a-command)")
]
);
assert_eq!(callback_timeout, Duration::from_secs(2));
assert_eq!(run_for, Some(Duration::from_secs(60)));
assert_eq!(poll_ms, 25);
}
#[test]
fn daemon_rejects_zero_callback_or_run_duration() {
for option in ["--callback-timeout", "--run-for"] {
assert!(Cli::try_parse_from([
"orient",
"--pile",
"unused.pile",
"daemon",
"--callback",
"/bin/true",
option,
"0s",
])
.is_err());
}
}
#[test]
fn receipt_import_requires_its_own_explicit_persona() {
let missing = Cli::try_parse_from([
"orient",
"--pile",
"unused.pile",
"--persona",
"ordinary-routing-selector",
"import-receipts",
])
.err()
.expect("top-level routing selection cannot choose a legacy import");
assert_eq!(
missing.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
#[test]
fn receipt_import_accepts_the_explicit_legacy_selector() {
let cli = Cli::try_parse_from([
"orient",
"--pile",
"unused.pile",
"import-receipts",
"--persona",
"legacy-alias",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::ImportReceipts { persona }) if persona == "legacy-alias"
));
}
}