#[cfg(feature = "connect")]
mod connect;
#[cfg(feature = "connect")]
mod reconcile;
mod render;
mod setup;
mod tui;
use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use costroid_core::{GroupBy, NowOptions, Period, TrendsOptions};
use costroid_providers::HostEnv;
use render::{
detect_render_options, render_frontier, render_statusline, render_trends, RenderMode,
};
#[cfg(feature = "connect")]
use costroid_connect::{ApiVendor, ConnectionRegistry, CredentialStore, SecretString};
#[derive(Debug, Parser)]
#[command(name = "costroid", version, about = "Local AI coding cost visibility")]
struct Cli {
#[arg(long, global = true, help = "Render plain ASCII output without color")]
plain: bool,
#[arg(long, global = true, help = "Refresh the selected view in place")]
live: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Trends(TrendsArgs),
Frontier,
Statusline(StatuslineArgs),
SetupStatusline(SetupStatuslineArgs),
Export(ExportArgs),
Alerts(AlertsArgs),
#[cfg(feature = "connect")]
Connect(VendorArgs),
#[cfg(feature = "connect")]
Disconnect(VendorArgs),
#[cfg(feature = "connect")]
Connections(ConnectionsArgs),
#[cfg(feature = "connect")]
Reconcile(ReconcileArgs),
}
#[cfg(feature = "connect")]
#[derive(Debug, Parser)]
struct VendorArgs {
#[arg(value_enum)]
vendor: VendorArg,
}
#[cfg(feature = "connect")]
#[derive(Debug, Parser)]
struct ConnectionsArgs {
#[arg(long)]
check: bool,
}
#[cfg(feature = "connect")]
#[derive(Debug, Clone, Copy, ValueEnum)]
enum VendorArg {
Anthropic,
Openai,
Gemini,
}
#[cfg(feature = "connect")]
#[derive(Debug, Parser)]
struct ReconcileArgs {
#[arg(long, value_enum)]
vendor: Option<ReconcileVendorArg>,
#[arg(long, value_enum, default_value_t = PeriodArg::Week)]
period: PeriodArg,
}
#[cfg(feature = "connect")]
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ReconcileVendorArg {
Anthropic,
Openai,
}
#[cfg(feature = "connect")]
impl From<ReconcileVendorArg> for ApiVendor {
fn from(value: ReconcileVendorArg) -> Self {
match value {
ReconcileVendorArg::Anthropic => ApiVendor::Anthropic,
ReconcileVendorArg::Openai => ApiVendor::OpenAI,
}
}
}
#[cfg(feature = "connect")]
impl From<VendorArg> for ApiVendor {
fn from(value: VendorArg) -> Self {
match value {
VendorArg::Anthropic => ApiVendor::Anthropic,
VendorArg::Openai => ApiVendor::OpenAI,
VendorArg::Gemini => ApiVendor::Gemini,
}
}
}
#[derive(Debug, Parser)]
struct StatuslineArgs {
#[arg(long, conflicts_with = "wrap")]
capture_only: bool,
#[arg(long, value_name = "COMMAND")]
wrap: Option<String>,
}
#[derive(Debug, Parser)]
struct SetupStatuslineArgs {
#[arg(long)]
undo: bool,
}
#[derive(Debug, Parser)]
struct TrendsArgs {
#[arg(long, value_enum, default_value_t = PeriodArg::Week)]
period: PeriodArg,
#[arg(long, value_enum, default_value_t = GroupArg::Model)]
group: GroupArg,
}
#[derive(Debug, Parser)]
struct ExportArgs {
#[arg(long, value_enum, default_value_t = ExportFormat::Json)]
format: ExportFormat,
}
#[derive(Debug, Parser)]
struct AlertsArgs {
#[arg(long)]
check: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum PeriodArg {
Day,
Week,
Month,
Year,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum GroupArg {
Model,
App,
Total,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ExportFormat {
Json,
Csv,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let render_options = detect_render_options(cli.plain);
match &cli.command {
Some(Command::Trends(args)) => {
if render_options.mode == RenderMode::Plain {
run_trends(args, render_options)?;
} else {
tui::run(
tui::StartScreen::Trends,
args.period.into(),
args.group.into(),
cli.live,
render_options,
)?;
}
}
Some(Command::Frontier) => {
if render_options.mode == RenderMode::Plain {
run_frontier(render_options)?;
} else {
tui::run(
tui::StartScreen::Frontier,
Period::Week,
GroupBy::Model,
cli.live,
render_options,
)?;
}
}
Some(Command::Statusline(args)) => {
run_statusline(args, render_options)?;
}
Some(Command::SetupStatusline(args)) => {
let env = HostEnv::detect();
setup::run_setup_statusline(&env, args.undo)?;
}
Some(Command::Export(args)) => {
run_export(args.format)?;
}
Some(Command::Alerts(args)) => {
std::process::exit(run_alerts(args, render_options)?);
}
#[cfg(feature = "connect")]
Some(Command::Connect(args)) => {
std::process::exit(run_connect_command(args.vendor.into(), cli.plain)?);
}
#[cfg(feature = "connect")]
Some(Command::Disconnect(args)) => {
std::process::exit(run_disconnect_command(args.vendor.into(), cli.plain)?);
}
#[cfg(feature = "connect")]
Some(Command::Connections(args)) => {
std::process::exit(run_connections_command(args.check, cli.plain)?);
}
#[cfg(feature = "connect")]
Some(Command::Reconcile(args)) => {
std::process::exit(run_reconcile_command(args, cli.plain)?);
}
None => {
if render_options.mode == RenderMode::Plain {
run_now(render_options)?;
} else {
tui::run(
tui::StartScreen::Now,
Period::Week,
GroupBy::Model,
cli.live,
render_options,
)?;
}
}
}
Ok(())
}
fn run_now(render_options: render::RenderOptions) -> Result<()> {
let env = HostEnv::detect();
let snapshot = costroid_core::collect_local_snapshot(&env)?;
let summary = costroid_core::now_summary(&snapshot, NowOptions::default());
let alerts = match costroid_config::load() {
Ok(config) if config.alerts_enabled() => compute_alerts(&config, &snapshot, &summary),
_ => Vec::new(),
};
print!(
"{}",
render::render_now_with_alerts(&summary, &alerts, render_options)
);
Ok(())
}
fn compute_alerts(
config: &costroid_config::Config,
snapshot: &costroid_core::EngineSnapshot,
summary: &costroid_core::NowSummary,
) -> Vec<costroid_core::Alert> {
let budget = costroid_core::budget_view(snapshot, &config.budget_targets());
let forecast = config
.alerts_forecast_enabled()
.then(|| costroid_core::forecast_view(snapshot));
let anomalies = config
.alerts_anomalies_enabled()
.then(|| costroid_core::anomalies_view(snapshot));
let advisory = costroid_core::AdvisoryAlerts {
forecast: forecast.as_ref(),
anomalies: anomalies.as_ref(),
};
costroid_core::active_alerts(summary, &budget, &config.alert_thresholds(), advisory)
}
fn run_alerts(args: &AlertsArgs, render_options: render::RenderOptions) -> Result<i32> {
let config = match costroid_config::load() {
Ok(config) => config,
Err(error) => {
eprintln!("config: {error}");
return Ok(2);
}
};
if !config.alerts_enabled() {
if !args.check {
print!("{}", render::render_alerts_off(render_options));
}
return Ok(0);
}
let env = HostEnv::detect();
let snapshot = match costroid_core::collect_local_snapshot(&env) {
Ok(snapshot) => snapshot,
Err(error) => {
eprintln!("alerts: {error}");
return Ok(2);
}
};
let summary = costroid_core::now_summary(&snapshot, NowOptions::default());
let alerts = compute_alerts(&config, &snapshot, &summary);
if args.check {
if !alerts.is_empty() {
println!("{}", render::alert_check_line(&alerts));
}
Ok(render::alerts_check_exit_code(&alerts))
} else {
print!("{}", render::render_alerts(&alerts, render_options));
Ok(0)
}
}
fn run_trends(args: &TrendsArgs, render_options: render::RenderOptions) -> Result<()> {
let env = HostEnv::detect();
let snapshot = costroid_core::collect_local_snapshot(&env)?;
let summary = costroid_core::trends_summary(
&snapshot,
TrendsOptions {
period: args.period.into(),
group_by: args.group.into(),
},
);
print!("{}", render_trends(&summary, render_options));
Ok(())
}
fn run_frontier(render_options: render::RenderOptions) -> Result<()> {
let env = HostEnv::detect();
let snapshot = costroid_core::collect_local_snapshot(&env)?;
let view = costroid_core::bench_view(&snapshot)?;
print!("{}", render_frontier(&view, render_options));
Ok(())
}
fn run_statusline(args: &StatuslineArgs, render_options: render::RenderOptions) -> Result<()> {
if args.capture_only {
setup::capture_from_bytes(&setup::read_stdin());
return Ok(());
}
if let Some(command) = &args.wrap {
return setup::run_wrap(command);
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
setup::capture_from_bytes(&setup::read_stdin());
}
let env = HostEnv::detect();
let snapshot = match costroid_core::collect_local_snapshot(&env) {
Ok(snapshot) => snapshot,
Err(_) => {
println!();
return Ok(());
}
};
let summary = costroid_core::now_summary(&snapshot, NowOptions::default());
print!("{}", render_statusline(&summary, render_options));
Ok(())
}
fn run_export(format: ExportFormat) -> Result<()> {
let env = HostEnv::detect();
let rows = costroid_core::focus_records_from_local_logs(&env)?;
let output = match format {
ExportFormat::Json => costroid_core::export_focus_json(rows)?,
ExportFormat::Csv => costroid_core::export_focus_csv(&rows)?,
};
print!("{output}");
Ok(())
}
#[cfg(feature = "connect")]
fn connect_output_style(plain: bool) -> connect::OutputStyle {
let mode = detect_render_options(plain).mode;
connect::OutputStyle {
ascii: mode != RenderMode::Braille,
}
}
#[cfg(feature = "connect")]
fn run_connect_command(vendor: ApiVendor, plain: bool) -> Result<i32> {
let style = connect_output_style(plain);
let mut stdout = std::io::stdout().lock();
if vendor == ApiVendor::Gemini {
return connect::gemini_connect(&mut stdout, style);
}
connect::print_connect_warning(&mut stdout, style, vendor)?;
let key = read_admin_key(vendor)?;
let store = CredentialStore::new()?;
let registry = ConnectionRegistry::open()?;
connect::run_connect(
vendor,
key,
&connect::RealAdapters,
&store,
®istry,
&mut stdout,
style,
)
}
#[cfg(feature = "connect")]
fn run_disconnect_command(vendor: ApiVendor, plain: bool) -> Result<i32> {
let style = connect_output_style(plain);
let mut stdout = std::io::stdout().lock();
let store = CredentialStore::new()?;
let registry = ConnectionRegistry::open()?;
connect::run_disconnect(vendor, &store, ®istry, &mut stdout, style)
}
#[cfg(feature = "connect")]
fn run_connections_command(check: bool, plain: bool) -> Result<i32> {
let style = connect_output_style(plain);
let mut stdout = std::io::stdout().lock();
let store = CredentialStore::new()?;
let registry = ConnectionRegistry::open()?;
connect::run_connections(
check,
&connect::RealAdapters,
&store,
®istry,
&mut stdout,
style,
)
}
#[cfg(feature = "connect")]
fn run_reconcile_command(args: &ReconcileArgs, plain: bool) -> Result<i32> {
let options = detect_render_options(plain);
let env = HostEnv::detect();
let rows = costroid_core::focus_records_from_local_logs(&env)?;
let store = CredentialStore::new()?;
let registry = ConnectionRegistry::open()?;
let window = reconcile::completed_window(args.period.into());
let mut stdout = std::io::stdout().lock();
reconcile::run_reconcile(
args.vendor.map(Into::into),
window,
&rows,
&connect::RealAdapters,
&store,
®istry,
&mut stdout,
options,
)
}
#[cfg(feature = "connect")]
fn read_admin_key(vendor: ApiVendor) -> Result<SecretString> {
use std::io::IsTerminal;
let mut raw = if std::io::stdin().is_terminal() {
rpassword::prompt_password(format!(
"Paste your {vendor} admin key (input hidden, not echoed): "
))?
} else {
use std::io::BufRead;
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
line
};
let lead = raw.len() - raw.trim_start().len();
raw.drain(..lead);
let end = raw.trim_end().len();
raw.truncate(end);
Ok(SecretString::from(raw))
}
impl From<PeriodArg> for Period {
fn from(value: PeriodArg) -> Self {
match value {
PeriodArg::Day => Self::Day,
PeriodArg::Week => Self::Week,
PeriodArg::Month => Self::Month,
PeriodArg::Year => Self::Year,
}
}
}
impl From<GroupArg> for GroupBy {
fn from(value: GroupArg) -> Self {
match value {
GroupArg::Model => Self::Model,
GroupArg::App => Self::App,
GroupArg::Total => Self::Total,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_shape_is_valid() {
Cli::command().debug_assert();
}
}