#[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_now, 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),
#[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, 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)?;
}
#[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());
print!("{}", render_now(&summary, render_options));
Ok(())
}
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();
}
}