mod config;
mod scan;
mod turn;
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use crate::config::Config;
use crate::turn::Backoff;
const CLAUDE_CREDENTIALS: &str = "/root/.claude/.credentials.json";
fn main() -> Result<()> {
let cfg = Config::from_env(std::env::args())?;
setup(&cfg)?;
let mut day = local_day()?;
let mut completed_today = 0u32;
loop {
let today = local_day()?;
if today != day {
day = today;
completed_today = 0;
}
if cfg.daily_limit.is_some_and(|limit| completed_today >= limit) {
wait_for_tomorrow(&day)?;
continue;
}
let report = turn::run(&cfg)?;
if report.completed {
completed_today += 1;
if let Some(limit) = cfg.daily_limit {
println!("completed {completed_today}/{limit} tasks today");
}
}
sleep(&cfg, report.backoff)?;
if cfg.once {
return Ok(());
}
}
}
fn local_day() -> Result<String> {
let output = Command::new("date")
.arg("+%F")
.output()
.context("failed to run date")?;
if !output.status.success() {
bail!("date exited with {}", output.status);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn wait_for_tomorrow(day: &str) -> Result<()> {
println!("daily limit reached; sleeping until tomorrow");
while local_day()? == day {
std::thread::sleep(Duration::from_secs(600));
}
Ok(())
}
fn setup(cfg: &Config) -> Result<()> {
run(Command::new("git").args(["config", "--system", "credential.helper", "store"]))?;
let credential = cfg.gitea_url.replacen(
"://",
&format!("://{}:{}@", cfg.gitea_user, cfg.gitea_token),
1,
);
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open("/root/.git-credentials")?
.write_all(format!("{credential}\n").as_bytes())?;
run(Command::new("tea").args([
"login",
"add",
"--name",
"gitea",
"--url",
&cfg.gitea_url,
"--token",
&cfg.gitea_token,
]))?;
let usable = std::fs::File::open(CLAUDE_CREDENTIALS)
.ok()
.and_then(|file| serde_json::from_reader::<_, serde_json::Value>(file).ok())
.is_some_and(|credentials| {
["accessToken", "refreshToken"].iter().all(|key| {
credentials["claudeAiOauth"][key]
.as_str()
.is_some_and(|token| !token.is_empty())
})
});
if !usable {
bail!("No usable Claude OAuth login at {CLAUDE_CREDENTIALS}.");
}
Ok(())
}
fn run(command: &mut Command) -> Result<()> {
let program = command.get_program().to_string_lossy().into_owned();
let status = command
.status()
.with_context(|| format!("failed to run {program}"))?;
if !status.success() {
bail!("{program} exited with {status}");
}
Ok(())
}
fn sleep(cfg: &Config, backoff: Backoff) -> Result<()> {
if cfg.once {
return Ok(());
}
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let seconds = match backoff {
Backoff::UsageLimit(epoch) if epoch > now => {
println!(
"usage: limit reached; sleeping until {}",
iso8601(epoch).unwrap_or_else(|| epoch.to_string())
);
epoch - now + 60
}
_ => 60,
};
std::thread::sleep(Duration::from_secs(seconds));
Ok(())
}
fn iso8601(epoch: u64) -> Option<String> {
let output = Command::new("date")
.args(["-d", &format!("@{epoch}"), "-Iseconds"])
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}