mod cache;
mod model;
mod providers;
mod registry;
mod render;
use std::io::IsTerminal;
use chrono::{DateTime, Utc};
use crate::cache::Cache;
use crate::model::{Account, Snapshot, Status};
use crate::providers::{FetchError, Provider};
const USAGE: &str = "\
quotch — AI coding-agent subscription usage at a glance
Usage: quotch [--json] [--raw] [--refresh] [-h|--help] [-V|--version]
quotch skill install install the quotch agent skill file
--json machine-readable JSON output --refresh ignore cache, always fetch
--raw include full provider response -h,--help show this help
-V,--version show version
";
const SKILL_MD: &str = include_str!("../skill/SKILL.md");
const SKILL_USAGE: &str = "\
Usage: quotch skill print
quotch skill install [--project | --dir <path>]
print print the skill file to stdout
install install to <home>/.claude/skills/quotch/SKILL.md
--project install to ./.claude/skills/quotch/SKILL.md
--dir <path> install to <path>/quotch/SKILL.md
";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("skill") {
run_skill(&args[1..]);
}
let mut json = false;
let mut raw = false;
let mut refresh = false;
for arg in &args {
match arg.as_str() {
"--json" => json = true,
"--raw" => raw = true,
"--refresh" => refresh = true,
"-h" | "--help" => {
print!("{USAGE}");
std::process::exit(0);
}
"-V" | "--version" => {
println!("quotch {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
_ => {
eprint!("{USAGE}");
std::process::exit(2);
}
}
}
let cache = Cache::new();
let providers = registry::all();
let mut pairs: Vec<(&dyn Provider, Account)> = Vec::new();
for p in &providers {
for acct in p.discover() {
pairs.push((p.as_ref(), acct));
}
}
let now = Utc::now();
let mut snaps: Vec<Option<Snapshot>> = vec![None; pairs.len()];
let mut cached_snaps: Vec<Option<Snapshot>> = Vec::with_capacity(pairs.len());
let mut to_fetch: Vec<usize> = Vec::new();
for (i, pair) in pairs.iter().enumerate() {
let acct = &pair.1;
let cached = cache.load(acct.provider, &acct.id);
if !refresh && cached.as_ref().is_some_and(|c| is_fresh(c, now)) {
snaps[i] = cached.clone();
} else if !refresh && cached.is_some() && cache.recent_failure(acct.provider, &acct.id) {
let mut snap = cached.clone().expect("checked is_some above");
snap.status = Status::Stale;
snaps[i] = Some(snap);
} else {
to_fetch.push(i);
}
cached_snaps.push(cached);
}
std::thread::scope(|s| {
let mut handles = Vec::new();
for &i in &to_fetch {
let p = pairs[i].0;
let acct = &pairs[i].1;
let cached = cached_snaps[i].clone();
let cache_ref = &cache;
let handle = s.spawn(move || fetch_one(p, acct, cache_ref, cached));
handles.push((i, p.id(), acct.id.clone(), acct.label.clone(), handle));
}
for (i, provider, account, label, handle) in handles {
snaps[i] = Some(match handle.join() {
Ok(snap) => snap,
Err(_) => Snapshot {
provider: provider.to_string(),
account,
label,
plan: None,
windows: vec![],
fetched_at: Utc::now(),
status: Status::Error,
error: Some("provider panicked".to_string()),
raw: None,
},
});
}
});
let snaps: Vec<Snapshot> = snaps.into_iter().flatten().collect();
if json {
print!("{}", render::json::render(snaps, raw));
} else {
let color = std::io::stdout().is_terminal();
let out = render::line::render(&snaps, color);
if !out.is_empty() {
println!("{out}");
}
}
}
fn run_skill(args: &[String]) -> ! {
match args.first().map(String::as_str) {
Some("print") => {
print!("{SKILL_MD}");
std::process::exit(0);
}
Some("install") => run_skill_install(&args[1..]),
_ => skill_usage_exit(),
}
}
fn run_skill_install(args: &[String]) -> ! {
let mut project = false;
let mut dir: Option<&str> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--project" => {
project = true;
i += 1;
}
"--dir" => {
dir = args.get(i + 1).map(String::as_str);
if dir.is_none() {
skill_usage_exit();
}
i += 2;
}
_ => skill_usage_exit(),
}
}
if project && dir.is_some() {
skill_usage_exit();
}
let path = if let Some(dir) = dir {
std::path::Path::new(dir).join("quotch").join("SKILL.md")
} else if project {
std::path::Path::new(".claude")
.join("skills")
.join("quotch")
.join("SKILL.md")
} else {
match std::env::home_dir() {
Some(home) => home
.join(".claude")
.join("skills")
.join("quotch")
.join("SKILL.md"),
None => {
eprintln!("error: could not determine home directory");
std::process::exit(1);
}
}
};
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("error: {e}");
std::process::exit(1);
}
match std::fs::write(&path, SKILL_MD) {
Ok(()) => {
println!("installed: {}", path.display());
std::process::exit(0);
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
fn skill_usage_exit() -> ! {
eprint!("{SKILL_USAGE}");
std::process::exit(2);
}
fn is_fresh(snap: &Snapshot, now: DateTime<Utc>) -> bool {
(now - snap.fetched_at)
.to_std()
.is_ok_and(|age| age < cache::TTL)
}
fn fetch_one(
p: &dyn Provider,
acct: &Account,
cache: &Cache,
cached: Option<Snapshot>,
) -> Snapshot {
match p.fetch(acct) {
Ok(snap) => {
cache.store(&snap);
cache.clear_failure(p.id(), &acct.id);
snap
}
Err(FetchError::AuthMissing) => Snapshot {
provider: p.id().to_string(),
account: acct.id.clone(),
label: acct.label.clone(),
plan: None,
windows: vec![],
fetched_at: Utc::now(),
status: Status::AuthMissing,
error: None,
raw: None,
},
Err(e) => {
cache.mark_failure(p.id(), &acct.id);
match cached {
Some(mut old) => {
old.status = Status::Stale;
old
}
None => Snapshot {
provider: p.id().to_string(),
account: acct.id.clone(),
label: acct.label.clone(),
plan: None,
windows: vec![],
fetched_at: Utc::now(),
status: Status::Error,
error: Some(e.to_string()),
raw: None,
},
}
}
}
}