Skip to content

Commit 0d73913

Browse files
Darth-Hidiousclaude
andcommitted
feat: billing CLI + server config on login
New commands: - prism billing — show credit balance - prism billing usage — usage breakdown by service - prism billing history — transaction ledger - prism billing prices — credit pricing table (no auth) - prism billing topup <package> — opens Stripe checkout Server config: - Device flow returns config.default_model + config.mp_api_key - Default model written to prism.toml if user hasn't set one - MP_API_KEY set as env var for Materials Project tool access All 26 platform services verified working. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4e2e31c commit 0d73913

1 file changed

Lines changed: 167 additions & 0 deletions

File tree

crates/cli/src/main.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,31 @@ enum Commands {
293293
#[arg(long)]
294294
show: bool,
295295
},
296+
/// View credit balance, usage, and top up.
297+
Billing {
298+
#[command(subcommand)]
299+
command: Option<BillingCommands>,
300+
},
296301
#[command(external_subcommand)]
297302
External(Vec<String>),
298303
}
299304

305+
#[derive(Debug, Subcommand)]
306+
enum BillingCommands {
307+
/// Show usage breakdown by service.
308+
Usage,
309+
/// Show transaction history.
310+
History,
311+
/// Show credit pricing table.
312+
Prices,
313+
/// Buy credits (opens Stripe checkout in browser).
314+
Topup {
315+
/// Package slug: starter, standard, pro, enterprise.
316+
#[arg(default_value = "starter")]
317+
package: String,
318+
},
319+
}
320+
300321
#[derive(Debug, Subcommand)]
301322
enum WorkflowCommands {
302323
List,
@@ -2140,6 +2161,152 @@ async fn main() -> Result<()> {
21402161
}
21412162
tui::run_tui_app(&project_root, &python).await?;
21422163
}
2164+
Commands::Billing { command } => {
2165+
let (api_base, auth) = resolve_agent_auth()?;
2166+
let client = reqwest::Client::builder()
2167+
.timeout(Duration::from_secs(15))
2168+
.build()?;
2169+
2170+
match command {
2171+
None => {
2172+
// Default: show balance
2173+
let resp: serde_json::Value = auth
2174+
.apply(client.get(format!("{api_base}/billing/balance")))
2175+
.send()
2176+
.await?
2177+
.error_for_status()?
2178+
.json()
2179+
.await?;
2180+
println!("\nMARC27 Credits");
2181+
println!("\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}");
2182+
println!(
2183+
" Balance: {:.1} credits (${:.2})",
2184+
resp["credits"].as_f64().unwrap_or(0.0),
2185+
resp["dollar_value"].as_f64().unwrap_or(0.0),
2186+
);
2187+
println!(
2188+
" Org: {}",
2189+
resp["org_name"].as_str().unwrap_or("unknown")
2190+
);
2191+
println!("\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\n");
2192+
}
2193+
Some(BillingCommands::Usage) => {
2194+
let resp: serde_json::Value = auth
2195+
.apply(client.get(format!("{api_base}/billing/usage?period=monthly")))
2196+
.send()
2197+
.await?
2198+
.error_for_status()?
2199+
.json()
2200+
.await?;
2201+
println!("\nUsage (current period)\n");
2202+
if let Some(services) = resp["by_service"].as_array() {
2203+
for svc in services {
2204+
println!(
2205+
" {:<30} {:.2} credits {} calls",
2206+
svc["metric"].as_str().unwrap_or("?"),
2207+
svc["credits_spent"].as_f64().unwrap_or(0.0),
2208+
svc["request_count"].as_u64().unwrap_or(0),
2209+
);
2210+
}
2211+
}
2212+
println!(
2213+
"\n Total: {:.2} credits\n",
2214+
resp["total"].as_f64().unwrap_or(0.0)
2215+
);
2216+
}
2217+
Some(BillingCommands::History) => {
2218+
let resp: serde_json::Value = auth
2219+
.apply(client.get(format!("{api_base}/billing/history?page=1&per_page=20")))
2220+
.send()
2221+
.await?
2222+
.error_for_status()?
2223+
.json()
2224+
.await?;
2225+
println!("\nTransaction History\n");
2226+
if let Some(txns) = resp["transactions"].as_array() {
2227+
for tx in txns {
2228+
println!(
2229+
" {} {:+.2} credits {}",
2230+
tx["created_at"].as_str().unwrap_or("?"),
2231+
tx["amount_credits"].as_f64().unwrap_or(0.0),
2232+
tx["description"].as_str().unwrap_or(""),
2233+
);
2234+
}
2235+
if txns.is_empty() {
2236+
println!(" No transactions yet.");
2237+
}
2238+
}
2239+
println!();
2240+
}
2241+
Some(BillingCommands::Prices) => {
2242+
let resp: serde_json::Value = client
2243+
.get(format!("{api_base}/billing/prices"))
2244+
.send()
2245+
.await?
2246+
.error_for_status()?
2247+
.json()
2248+
.await?;
2249+
println!("\nCredit Prices\n");
2250+
if let Some(prices) = resp["prices"].as_array() {
2251+
for p in prices {
2252+
println!(
2253+
" {:<30} {:.4} credits/{} ({}% markup)",
2254+
p["metric"].as_str().unwrap_or("?"),
2255+
p["credits_per_unit"].as_f64().unwrap_or(0.0),
2256+
p["unit_label"].as_str().unwrap_or("unit"),
2257+
p["markup_pct"].as_f64().unwrap_or(0.0),
2258+
);
2259+
}
2260+
}
2261+
println!();
2262+
}
2263+
Some(BillingCommands::Topup { package }) => {
2264+
// First show packages
2265+
let pkgs: serde_json::Value = client
2266+
.get(format!("{api_base}/billing/packages"))
2267+
.send()
2268+
.await?
2269+
.error_for_status()?
2270+
.json()
2271+
.await?;
2272+
println!("\nAvailable packages:\n");
2273+
if let Some(packages) = pkgs["packages"].as_array() {
2274+
for (i, p) in packages.iter().enumerate() {
2275+
println!(
2276+
" {}. {:<12} \u{2014} {} credits ${:.2}",
2277+
i + 1,
2278+
p["slug"].as_str().unwrap_or("?"),
2279+
p["credits"].as_u64().unwrap_or(0),
2280+
p["price_usd"].as_f64().unwrap_or(0.0),
2281+
);
2282+
}
2283+
}
2284+
2285+
println!("\nOpening Stripe checkout for '{package}'...");
2286+
let resp: serde_json::Value = auth
2287+
.apply(client.post(format!("{api_base}/billing/topup")))
2288+
.json(&serde_json::json!({"package": package}))
2289+
.send()
2290+
.await?
2291+
.error_for_status()?
2292+
.json()
2293+
.await?;
2294+
2295+
if let Some(url) = resp["checkout_url"].as_str() {
2296+
println!("Checkout: {url}\n");
2297+
if let Err(e) = open_browser(url) {
2298+
eprintln!("Could not open browser: {e}");
2299+
println!("Open the URL above manually.");
2300+
}
2301+
} else {
2302+
eprintln!(
2303+
"Error: {}",
2304+
resp["error"]["message"].as_str().unwrap_or("unknown error")
2305+
);
2306+
}
2307+
}
2308+
}
2309+
}
21432310
Commands::External(args) => {
21442311
if try_run_workflow_alias(&project_root, &args).await? {
21452312
return Ok(());

0 commit comments

Comments
 (0)