diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fd3cc..69d7c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,23 @@ publishing empty notes. ### Added +- **Tools name the plan they are signed into, and what the seat costs.** The + Tools view gains PLAN and `$/MO` columns, and the SPEND card and Cost view + price the same figure: Claude Code's `~/.claude.json` names the seat — + priced by its rate-limit tier when that names a known capacity, because a + Team premium seat reads the same seat slug as a standard one and pricing by + it under-reported a real premium seat by 70% — and Codex's + `~/.codex/auth.json` token payload names `chatgpt_plan_type`. The account + file — what the tool is on *now* — beats the plan its transcripts name, and + `[cost.subscriptions]` beats both; the `≈` on an estimated figure marks + exactly what one config line makes exact. Only the plan's name is read from + those files, never the credentials beside it, and nothing is fetched. + `--json` carries it as `tools.list[].plan` with its source. The built-in + table prices ChatGPT's `team` plan at $25 after OpenAI's April 2026 + repricing of Business, splitting it from Claude's `team_tier_1` at $30 — + the table carries monthly-billing list rates, and annual billing being + lower is one more reason every figure from it is marked an estimate. + - **A SPEND card that prices seats, and a TOKEN COST card that prices tokens.** The Overview's old SPEND figure — the window's tokens at API list rates — now sits under the name it deserved, **TOKEN COST**. The **SPEND** card answers diff --git a/README.md b/README.md index 9ca5ab2..d2ec343 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,12 @@ as zero AI usage. Full detail in - **Local models are free**, and shown as `local` rather than `$0.00`, because the two mean different things. - **These are API list rates.** Put what you actually pay in - `[cost.subscriptions]` and the Cost view compares the two. Without that, a plan - the tool's own transcripts name (Codex writes one beside its token counts) is - priced at its list rate and marked an estimate — surface still reads no account - state, and a tool naming no plan is never guessed at. + `[cost.subscriptions]` and the Cost view compares the two. Without that, the + plan a tool itself names — in the account file it keeps on this disk, or + beside the token counts in its transcripts — is priced at its list rate and + marked an estimate. The plan's name is the only thing read from those files, + never the credentials beside it, and a tool naming no plan is never guessed + at. - **Cache reads are billed at cache rates** and reasoning tokens at output rates, which is how the providers that distinguish them do it. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bc00566..63e140c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -107,11 +107,13 @@ codex = 30.0 Keys are tool ids as they appear in the Usage view — `claude_code`, `codex`, `opencode`, `gemini_cli`, … — and values are **monthly** USD. -A tool with no entry falls back to the plan its own transcripts name, if any — -Codex writes one beside its token counts — priced at that plan's published list -rate. surface reads no account state, so a tool that names no plan and has no -entry gets no row rather than a guess. A configured figure always wins and is -used as given; a list-price fallback is labelled `est` wherever it is shown. +A tool with no entry falls back to the plan it names itself, if any — its +account file first (`~/.claude.json`, `~/.codex/auth.json`), its transcripts +otherwise — priced at that plan's published list rate. Only the plan's name is +read from those files, never the credentials beside it, and a tool that names +no plan and has no entry gets no row rather than a guess. A configured figure +always wins and is used as given; a list-price fallback is labelled `est` +wherever it is shown. ## Environment variables diff --git a/docs/guide/costs.md b/docs/guide/costs.md index d48bd6d..bc8da7a 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -75,13 +75,28 @@ claude_code = 100.0 └────────────────────────────────────────────────────────────────────┘ ``` -Without an entry, one more source is tried before giving up: the plan a tool's -own transcripts name. Codex writes `rate_limits.plan_type` beside its token -counts, and a plan named there is priced at its published list rate — suffixed -`est` in the table and marked `≈` on the SPEND card, because a list price is an -estimate of your bill, not your bill. surface still reads no account state — no -billing API, no session token — so a tool that names no plan gets no row rather -than a guess, and a configured entry always beats a detected plan. +Without an entry, the plan the tool itself names is tried before giving up — +two sources, most current first: + +- **The tool's account file.** Claude Code caches the seat it is signed into + in `~/.claude.json` — priced by its `userRateLimitTier` when that names a + known capacity (`default_claude_max_5x`), because a Team premium seat reads + the same `seatTier` as a standard one and pricing by the seat slug + under-reported it by 70%; the seat tier is the fallback. Codex's + `~/.codex/auth.json` token payload names `chatgpt_plan_type`. These say what + the tool is on *right now*. +- **Its transcripts.** Codex also writes `rate_limits.plan_type` beside its + token counts — the plan that was active when the usage was written. + +A plan named either way is priced at its published **monthly** list rate — +suffixed `est` in the table and marked `≈` on the SPEND card, because a list +price is an estimate of your bill, not your bill. Annual billing is lower +(ChatGPT Business bills $20 per seat annually against the $25 monthly rate), +and negotiated or legacy rates differ too — which is exactly what the config +entry is for. Nothing is fetched: these are files +already on this disk, the plan's *name* is the only thing read out of them — +never the credentials beside it — and a tool that names no plan gets no row +rather than a guess. A configured entry always beats a detected plan. ## How each token kind is billed diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index a1ae6e0..eaa4cd6 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -67,12 +67,13 @@ one of those is a caveat rather than a count. SPEND and TOKEN COST are different questions about the same tokens. TOKEN COST is arithmetic — the window's usage priced per token at list rates. SPEND is what is actually paid for the seats behind that usage: a figure from -`[cost.subscriptions]` is shown plainly; one from a plan the tool's own -transcripts name (Codex writes `plan_type` beside its token counts) is priced -at that plan's list rate and marked `≈` an estimate; a tool with usage but no -figure makes the total `≥` a floor. Knowing nothing, the card shows `–` and -says how to configure it — it never guesses, and it still reads no account -state. +`[cost.subscriptions]` is shown plainly; one from a plan the tool itself names +— its account file first (`~/.claude.json`, `~/.codex/auth.json`), its +transcripts as the fallback — is priced at that plan's list rate and marked +`≈` an estimate; a tool with usage but no figure makes the total `≥` a floor. +Knowing nothing, the card shows `–` and says how to configure it. Nothing is +fetched, nothing is guessed, and the plan's *name* is the only thing read from +those files — never the credentials that sit beside it. ### The rate and the delta @@ -138,6 +139,8 @@ One row per detected tool. | **VENDOR** | Who ships it | | **KIND** | `assistant`, `coding agent`, `autonomous agent`, `editor`, `extension`, `local runtime` | | **CAN ACT** | Whether it can execute code or take actions on this machine | +| **PLAN** | The subscription plan the tool is signed into, as the raw slug it wrote (`default_claude_max_5x`, `team`) — from its account file, or its transcripts as the fallback. `–` when it names none | +| **$/MO** | What that seat costs per month: plain from `[cost.subscriptions]`, `≈` at the plan's list price. The `≈` is the invitation — a figure that looks wrong is fixed with one config line | | **FOUND BY** | The evidence that produced the detection | **CAN ACT** is the column worth reading first. A chat window that can only talk diff --git a/src/app.rs b/src/app.rs index 6b1dab6..c2e7f28 100644 --- a/src/app.rs +++ b/src/app.rs @@ -143,6 +143,13 @@ pub struct ToolRow { pub vendor: &'static str, pub kind: &'static str, pub autonomous: bool, + /// The subscription plan the tool is on, when its account file or + /// transcripts name one. The raw slug, as the tool wrote it. + pub plan: Option, + /// What that seat costs per month — `[cost.subscriptions]` if set, the + /// plan's list price otherwise — and whether it is an estimate. Shown + /// beside the plan so a wrong estimate is visible where it can be fixed. + pub monthly: Option<(f64, bool)>, pub evidence: Vec, } @@ -349,12 +356,18 @@ impl App { .scan .tools .iter() - .map(|d| ToolRow { - name: d.tool.name, - vendor: d.tool.vendor, - kind: d.tool.kind.label(), - autonomous: d.tool.autonomous, - evidence: d.evidence.clone(), + .map(|d| { + let usage_id = crate::scan::plans::usage_tool_id(d.tool.id); + let plan = self.scan.plans.get(usage_id).map(|p| p.plan.clone()); + ToolRow { + name: d.tool.name, + vendor: d.tool.vendor, + kind: d.tool.kind.label(), + autonomous: d.tool.autonomous, + monthly: self.cost_config.monthly(usage_id, plan.as_deref()), + plan, + evidence: d.evidence.clone(), + } }) .collect(); @@ -769,11 +782,12 @@ impl App { .spend_by_tool() .into_iter() .filter_map(|(tool, api_equivalent)| { - // A configured subscription wins. Otherwise the plan the - // tool's own transcripts name is priced at its list rate and - // flagged as an estimate — still no account state read, and a - // tool naming no plan stays absent rather than guessed at. - let plan = self.ledger().plans.get(&tool).map(String::as_str); + // A configured subscription wins. Otherwise the plan the tool + // itself names — its account file first, its transcripts as + // the fallback, merged in `scan::run` — is priced at its list + // rate and flagged as an estimate. A tool naming no plan + // stays absent rather than guessed at. + let plan = self.scan.plans.get(&tool).map(|p| p.plan.as_str()); let (monthly, estimated) = self.cost_config.monthly(&tool, plan)?; Some(SubscriptionRow { tool, diff --git a/src/config.rs b/src/config.rs index c69885f..61165b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -124,16 +124,34 @@ impl CostConfig { if let Some(configured) = self.subscriptions.get(tool) { return Some((*configured, false)); } - let plan = plan?.to_lowercase(); - let listed = match plan.as_str() { - "pro" | "plus" => 20.0, - "max_5x" | "default_claude_max_5x" => 100.0, - "max_20x" => 200.0, - "team" | "team_tier_1" => 30.0, - // Enterprise and API-key access have no list price to assume. - _ => return None, - }; - Some((listed, true)) + Some((list_price(plan?)?, true)) + } +} + +/// The published list price for a plan slug, if it has one. +/// +/// Also how plan *detection* decides which of two slugs is worth keeping — +/// see [`crate::scan::plans`] — so an unknown plan returns `None` rather than +/// a guess in both places. Enterprise and API-key access have no list price +/// to assume. +/// +/// These are **monthly-billing** list rates; annual billing is lower (ChatGPT +/// Business is $20/seat annual against the $25 here), which is one more +/// reason every figure from this table is marked an estimate and a +/// `[cost.subscriptions]` entry wins. Each slug belongs to one vendor's +/// namespace — a collision with a different price would need this table keyed +/// by tool as well, so keep them labelled. +pub(crate) fn list_price(plan: &str) -> Option { + match plan.to_lowercase().as_str() { + // Claude: seat and rate-limit tiers. + "pro" | "plus" => Some(20.0), + "max_5x" | "default_claude_max_5x" => Some(100.0), + "max_20x" | "default_claude_max_20x" => Some(200.0), + "team_tier_1" => Some(30.0), + // ChatGPT: `chatgpt_plan_type`. Business (né Team) was repriced + // 2026-04-02 from $30 to $25 monthly. + "team" => Some(25.0), + _ => None, } } @@ -221,6 +239,19 @@ mod tests { ); } + #[test] + fn the_two_team_slugs_price_by_their_own_vendor() { + // ChatGPT Business (`team`) was repriced to $25 monthly in April + // 2026; Claude's `team_tier_1` seat stays $30. One shared arm was + // quietly wrong for one vendor. + let cost = CostConfig::default(); + assert_eq!(cost.monthly("codex", Some("team")), Some((25.0, true))); + assert_eq!( + cost.monthly("claude_code", Some("team_tier_1")), + Some((30.0, true)) + ); + } + #[test] fn an_unknown_plan_is_none_rather_than_a_guess() { let cost = CostConfig::default(); diff --git a/src/demo.rs b/src/demo.rs index 9eadadd..f76a017 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -25,12 +25,14 @@ //! is derived from today — so two runs on the same day produce the same //! dashboard. That matters for screenshots and for reproducing a layout bug. +use std::collections::BTreeMap; + use chrono::{Datelike, Duration, Utc, Weekday}; use crate::ledger::{Ledger, Tokens}; #[cfg(feature = "sqlite")] use crate::scan::sites; -use crate::scan::{tooling, usage, Scan, Timings}; +use crate::scan::{plans, tooling, usage, Scan, Timings}; /// Days of history the demo covers. Matches the default usage window. const WINDOW_DAYS: u64 = 30; @@ -51,6 +53,24 @@ pub fn scan() -> (Scan, Timings) { #[cfg(feature = "sqlite")] sites: sites(), usage: usage(), + // The same plans the seeded transcripts and account files would + // name on a real machine of this shape. + plans: BTreeMap::from([ + ( + "claude_code".to_string(), + plans::DetectedPlan { + plan: "max_20x".to_string(), + source: plans::PlanSource::Account, + }, + ), + ( + "codex".to_string(), + plans::DetectedPlan { + plan: "team".to_string(), + source: plans::PlanSource::Transcript, + }, + ), + ]), failed: Vec::new(), demo: true, }, diff --git a/src/main.rs b/src/main.rs index 473a2d7..e07aff4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -378,6 +378,17 @@ fn print_json(scan: &scan::Scan, timings: &scan::Timings, prices: &pricing::Pric "vendor": d.tool.vendor, "kind": d.tool.kind, "autonomous": d.tool.autonomous, + // The plan slug the tool itself names, and where it said so + // ("account" or "transcript"). Null when it names none. + "plan": scan.plans + .get(crate::scan::plans::usage_tool_id(d.tool.id)) + .map(|p| json!({ + "name": p.plan, + "source": match p.source { + crate::scan::plans::PlanSource::Account => "account", + crate::scan::plans::PlanSource::Transcript => "transcript", + }, + })), "evidence": d.evidence, })).collect::>(), }, diff --git a/src/scan/mod.rs b/src/scan/mod.rs index f165539..46445bb 100644 --- a/src/scan/mod.rs +++ b/src/scan/mod.rs @@ -12,11 +12,13 @@ //! release profile deliberately does not set `panic = "abort"`. pub mod apps; +pub mod plans; #[cfg(feature = "sqlite")] pub mod sites; pub mod tooling; pub mod usage; +use std::collections::BTreeMap; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; use std::time::Instant; @@ -32,6 +34,9 @@ pub struct Scan { #[cfg(feature = "sqlite")] pub sites: sites::Sites, pub usage: usage::Usage, + /// The subscription plan each tool is on, keyed by usage tool id. Account + /// files first, transcripts filling the gaps — see [`plans`]. + pub plans: BTreeMap, /// Sections that panicked, by name. Empty is the normal case. pub failed: Vec<&'static str>, /// Built by [`crate::demo`] rather than read off this machine. Never true @@ -85,6 +90,10 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) { .unwrap_or_default(); timings.usage_ms = mark.elapsed().as_millis(); + // Two file reads; not worth a timing of its own. + let mut plans = section("plans", &mut failed, plans::scan).unwrap_or_default(); + plans::merge_transcripts(&mut plans, &usage.ledger.plans); + timings.total_ms = started.elapsed().as_millis(); ( @@ -94,6 +103,7 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) { #[cfg(feature = "sqlite")] sites, usage, + plans, failed, demo: false, }, diff --git a/src/scan/plans.rs b/src/scan/plans.rs new file mode 100644 index 0000000..94da8b1 --- /dev/null +++ b/src/scan/plans.rs @@ -0,0 +1,312 @@ +//! The subscription plan an AI tool's own account file names. +//! +//! Two tools cache, beside their other local state, which plan they are +//! signed into: Claude Code writes an `oauthAccount` block into +//! `~/.claude.json`, and Codex keeps an OpenID token in `~/.codex/auth.json` +//! whose payload carries a `chatgpt_plan_type` claim. Both are files already +//! on this disk — nothing is fetched, and this is still not reading account +//! *state*, only the name of the plan the tool itself wrote down. +//! +//! # What leaves this module +//! +//! The plan's name, per tool. Nothing else. The files parsed here also hold +//! access tokens; those are read into memory only as far as finding the one +//! claim requires, and are never stored, logged, shown or compared. The token +//! signature is not verified — this is not authentication, it is reading a +//! label off a file the tool already trusts. +//! +//! # Why bother, when transcripts also name a plan +//! +//! A transcript names the plan that was active when usage was written; the +//! account file names the plan the tool is signed into *now*, and exists even +//! for a tool that has not run in the window. Where both speak, the account +//! file wins — see the merge in [`crate::scan::run`]. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Where a detected plan came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanSource { + /// The tool's account file: what it is signed into right now. + Account, + /// A transcript record: what it was on when it last wrote usage. + Transcript, +} + +/// One tool's detected plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedPlan { + pub plan: String, + pub source: PlanSource, +} + +/// Plans named by account files, keyed by *usage* tool id. +pub fn scan() -> BTreeMap { + let Some(home) = crate::paths::home() else { + return BTreeMap::new(); + }; + let mut plans = BTreeMap::new(); + if let Some(plan) = claude_seat(&home.join(".claude.json")) { + plans.insert( + "claude_code".to_string(), + DetectedPlan { + plan, + source: PlanSource::Account, + }, + ); + } + if let Some(plan) = codex_plan(&home.join(".codex/auth.json")) { + plans.insert( + "codex".to_string(), + DetectedPlan { + plan, + source: PlanSource::Account, + }, + ); + } + plans +} + +/// Fold transcript-named plans in behind account-named ones. +/// +/// The account file is what the tool is signed into *now*; a transcript names +/// the plan that was active when usage was written. Where both speak, now +/// wins. +pub fn merge_transcripts( + plans: &mut BTreeMap, + transcripts: &BTreeMap, +) { + for (tool, plan) in transcripts { + plans.entry(tool.clone()).or_insert_with(|| DetectedPlan { + plan: plan.clone(), + source: PlanSource::Transcript, + }); + } +} + +/// The usage-ledger tool id for a detection id, where the two disagree. +/// +/// Usage sources predate the tool table and named Codex `codex`; the +/// detection table calls it `openai_codex`. Plans are keyed the usage way +/// because pricing is, so the Tools view translates through this. +pub fn usage_tool_id(detection_id: &str) -> &str { + match detection_id { + "openai_codex" => "codex", + other => other, + } +} + +/// The seat named by Claude Code's `~/.claude.json`. +/// +/// Two fields describe it, and the *capacity* one prices better. A Team +/// premium seat reads `seatTier: team_tier_1` — the same slug as a standard +/// seat — while its `userRateLimitTier: default_claude_max_5x` names the +/// Max-class capacity actually being paid for; pricing by the seat slug +/// under-reported a real premium seat by 70%. So: the rate-limit tier when +/// it carries a known list price, the seat tier otherwise (a standard seat's +/// rate tier is unpriceable, a personal plan names no seat at all). Only +/// `oauthAccount` is looked at — the rest of the file is never walked. +fn claude_seat(path: &Path) -> Option { + let raw = std::fs::read(path).ok()?; + let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; + let account = value.get("oauthAccount")?; + let field = |key: &str| { + account + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let rate = field("userRateLimitTier"); + match rate { + Some(rate) if crate::config::list_price(&rate).is_some() => Some(rate), + rate => field("seatTier").or(rate), + } +} + +/// The plan named by Codex's `~/.codex/auth.json`. +/// +/// The file holds an OpenID `id_token` whose payload is a base64url JSON +/// object carrying `chatgpt_plan_type` under the `https://api.openai.com/auth` +/// claim. The payload is decoded for that one field; the token is neither +/// verified nor kept. +fn codex_plan(path: &Path) -> Option { + let raw = std::fs::read(path).ok()?; + let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; + let token = value.get("tokens")?.get("id_token")?.as_str()?; + plan_in_id_token(token) +} + +/// `chatgpt_plan_type` out of a JWT, without trusting anything else in it. +fn plan_in_id_token(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)?).ok()?; + claims + .get("https://api.openai.com/auth")? + .get("chatgpt_plan_type")? + .as_str() + .filter(|p| !p.is_empty()) + .map(str::to_string) +} + +/// Base64url without padding, as JWT segments are written. Hand-rolled: +/// fifteen lines against a new dependency for one segment of one file. +fn base64url(s: &str) -> Option> { + let value = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(u32::from(c - b'A')), + b'a'..=b'z' => Some(u32::from(c - b'a') + 26), + b'0'..=b'9' => Some(u32::from(c - b'0') + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + }; + let mut out = Vec::with_capacity(s.len() * 3 / 4); + for chunk in s.as_bytes().chunks(4) { + if chunk.len() == 1 { + return None; + } + let mut acc = 0u32; + for (i, &c) in chunk.iter().enumerate() { + acc |= value(c)? << (18 - 6 * i); + } + let bytes = [(acc >> 16) as u8, (acc >> 8) as u8, acc as u8]; + out.extend_from_slice(&bytes[..chunk.len() - 1]); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn temp_file(name: &str, contents: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("surface-plans"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + path + } + + /// A JWT in shape only: unsigned, unverified, exactly like the parser + /// treats the real one. + fn fake_jwt(claims: serde_json::Value) -> String { + fn encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let mut acc = 0u32; + for (i, &b) in chunk.iter().enumerate() { + acc |= u32::from(b) << (16 - 8 * i); + } + for i in 0..=chunk.len() { + out.push(ALPHABET[((acc >> (18 - 6 * i)) & 63) as usize] as char); + } + } + out + } + format!( + "{}.{}.x", + encode(b"{}"), + encode(claims.to_string().as_bytes()) + ) + } + + #[test] + fn a_premium_seat_is_priced_by_its_capacity_not_its_seat_slug() { + // seatTier reads `team_tier_1` for standard and premium seats alike; + // the rate tier is what tells them apart, so it wins when priceable. + let path = temp_file( + "claude.json", + &json!({"oauthAccount": { + "seatTier": "team_tier_1", + "userRateLimitTier": "default_claude_max_5x", + "emailAddress": "someone@example.com" + }}) + .to_string(), + ); + assert_eq!(claude_seat(&path).as_deref(), Some("default_claude_max_5x")); + } + + #[test] + fn a_standard_seat_keeps_its_seat_slug_and_gaps_stay_honest() { + // A standard seat's rate tier names no priced capacity. + let path = temp_file( + "claude-standard.json", + &json!({"oauthAccount": { + "seatTier": "team_tier_1", + "userRateLimitTier": "default_claude" + }}) + .to_string(), + ); + assert_eq!(claude_seat(&path).as_deref(), Some("team_tier_1")); + + // A personal plan names no seat at all. + let path = temp_file( + "claude-personal.json", + &json!({"oauthAccount": {"userRateLimitTier": "default_claude_max_5x"}}).to_string(), + ); + assert_eq!(claude_seat(&path).as_deref(), Some("default_claude_max_5x")); + + let path = temp_file("claude-empty.json", &json!({"projects": {}}).to_string()); + assert_eq!(claude_seat(&path), None, "signed out is None, not a guess"); + } + + #[test] + fn the_codex_plan_is_read_from_the_token_payload() { + let jwt = fake_jwt(json!({ + "https://api.openai.com/auth": {"chatgpt_plan_type": "team"}, + "email": "someone@example.com" + })); + let path = temp_file( + "auth.json", + &json!({"tokens": {"id_token": jwt}}).to_string(), + ); + assert_eq!(codex_plan(&path).as_deref(), Some("team")); + } + + #[test] + fn a_token_without_the_claim_is_none_rather_than_an_error() { + assert_eq!(plan_in_id_token(&fake_jwt(json!({"sub": "u-1"}))), None); + assert_eq!(plan_in_id_token("not-a-jwt"), None); + assert_eq!(plan_in_id_token(""), None); + } + + #[test] + fn missing_files_scan_to_nothing() { + assert_eq!(claude_seat(Path::new("/nonexistent/claude.json")), None); + assert_eq!(codex_plan(Path::new("/nonexistent/auth.json")), None); + } + + #[test] + fn the_detection_id_translates_to_the_usage_id() { + assert_eq!(usage_tool_id("openai_codex"), "codex"); + assert_eq!(usage_tool_id("claude_code"), "claude_code"); + } + + #[test] + fn an_account_plan_beats_a_transcript_plan_and_gaps_are_filled() { + let mut plans = BTreeMap::from([( + "codex".to_string(), + DetectedPlan { + plan: "team".to_string(), + source: PlanSource::Account, + }, + )]); + let transcripts = BTreeMap::from([ + ("codex".to_string(), "pro".to_string()), + ("claude_code".to_string(), "max_5x".to_string()), + ]); + + merge_transcripts(&mut plans, &transcripts); + + assert_eq!(plans["codex"].plan, "team", "now beats then"); + assert_eq!(plans["codex"].source, PlanSource::Account); + assert_eq!(plans["claude_code"].plan, "max_5x", "gap filled"); + assert_eq!(plans["claude_code"].source, PlanSource::Transcript); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 288041b..ecb1608 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -826,11 +826,34 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { } else { Span::styled("\u{2013}", Style::default().fg(theme::DIM)) }; + // The raw slug the tool wrote (`team_tier_1`), not a prettied + // name: the same string [cost.subscriptions] docs and the price + // table speak, so a reader can act on what they see. + let plan = match &t.plan { + Some(plan) => Span::styled(plan.clone(), Style::default().fg(theme::MUTED)), + None => Span::styled("\u{2013}", Style::default().fg(theme::DIM)), + }; + // The price beside the plan, in the same grammar as everywhere + // else: plain when configured, `≈` when it is a list-price + // estimate — which is exactly where a wrong estimate gets seen + // and corrected with a `[cost.subscriptions]` entry. + let seat = match t.monthly { + Some((usd, false)) => { + Span::styled(format_usd(usd), Style::default().fg(theme::MONEY)) + } + Some((usd, true)) => Span::styled( + format!("\u{2248}{}", format_usd(usd)), + Style::default().fg(theme::MUTED), + ), + None => Span::styled("\u{2013}", Style::default().fg(theme::DIM)), + }; Row::new(vec![ Cell::from(t.name), Cell::from(t.vendor), Cell::from(t.kind), Cell::from(Line::from(flag)), + Cell::from(Line::from(plan)), + Cell::from(Line::from(seat)), Cell::from(truncate(&t.evidence.join(", "), 60)), ]) }) @@ -843,10 +866,14 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { Constraint::Length(14), Constraint::Length(18), Constraint::Length(14), + Constraint::Length(22), + Constraint::Length(10), Constraint::Min(20), ], ) - .header(header(&["TOOL", "VENDOR", "KIND", "CAN ACT", "FOUND BY"])) + .header(header(&[ + "TOOL", "VENDOR", "KIND", "CAN ACT", "PLAN", "$/MO", "FOUND BY", + ])) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .block(panel(&format!( "{} AI tools \u{b7} {} can act on this machine", @@ -2040,6 +2067,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2117,6 +2145,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2270,7 +2299,6 @@ mod tests { fn a_detected_plan_prices_the_spend_card_as_an_estimate() { let mut ledger = Ledger::default(); ledger.add("2026-07-26", "codex", "gpt-5.6", &tokens(1_000, 2_000)); - ledger.observe_plan("codex", "team"); let scan = Scan { tools_summary: Default::default(), @@ -2282,6 +2310,14 @@ mod tests { window_days: 30, ..Default::default() }, + // As `scan::run` would have merged it, from either source. + plans: std::collections::BTreeMap::from([( + "codex".to_string(), + crate::scan::plans::DetectedPlan { + plan: "team".to_string(), + source: crate::scan::plans::PlanSource::Transcript, + }, + )]), failed: Vec::new(), demo: false, }; @@ -2295,8 +2331,8 @@ mod tests { let out = rendered(&app, 150, 40); assert!( - out.contains("\u{2248}$30.00/mo"), - "the team list price, flagged an estimate" + out.contains("\u{2248}$25.00/mo"), + "ChatGPT Business's monthly list price, flagged an estimate" ); assert!(out.contains("1 plan(s) detected")); } @@ -2328,6 +2364,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2353,6 +2390,92 @@ mod tests { assert!(out.contains("1 tool(s) unpriced")); } + /// The Tools view names the plan each tool is signed into, as the raw + /// slug the tool wrote — and a tool naming none shows a dash, not a guess. + #[test] + fn the_tools_view_names_the_plan_a_tool_is_on() { + let detected: Vec = tooling::AI_TOOLS + .iter() + .take(3) + .map(|tool| tooling::Detected { + tool, + evidence: vec!["config:~/.x".to_string()], + }) + .collect(); + let scan = Scan { + tools_summary: tooling::summarise(&detected), + tools: detected, + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: Default::default(), + plans: std::collections::BTreeMap::from([( + "claude_code".to_string(), + crate::scan::plans::DetectedPlan { + plan: "team_tier_1".to_string(), + source: crate::scan::plans::PlanSource::Account, + }, + )]), + failed: Vec::new(), + demo: false, + }; + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + CostConfig::default(), + ); + app.set_tab(Tab::Tools); + + let out = rendered(&app, 150, 30); + assert!(out.contains("PLAN"), "the plan column header"); + assert!(out.contains("team_tier_1"), "the slug the tool wrote"); + assert!(out.contains("$/MO"), "the seat price column header"); + assert!( + out.contains("\u{2248}$30.00"), + "the plan's list price, marked an estimate" + ); + } + + /// A configured seat price shows plain — the estimate marker is what + /// tells the reader which figures are worth correcting in config. + #[test] + fn a_configured_seat_price_shows_plain_on_the_tools_view() { + let detected: Vec = tooling::AI_TOOLS + .iter() + .take(1) + .map(|tool| tooling::Detected { + tool, + evidence: vec!["config:~/.x".to_string()], + }) + .collect(); + let scan = Scan { + tools_summary: tooling::summarise(&detected), + tools: detected, + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: Default::default(), + plans: Default::default(), + failed: Vec::new(), + demo: false, + }; + let mut cost = CostConfig::default(); + cost.subscriptions.insert("claude_code".into(), 100.0); + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + cost, + ); + app.set_tab(Tab::Tools); + + let out = rendered(&app, 150, 30); + assert!(out.contains("$100.00"), "the configured figure"); + assert!( + !out.contains("\u{2248}$100.00"), + "configured is not an estimate" + ); + } + /// The TOKEN COST card names what its dollars are: list-rate arithmetic /// when every model is priced, the unpriced caveat when one is not — one /// line, never both. Renamed with the card, and rendered at 150: five @@ -2427,6 +2550,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2472,6 +2596,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2523,6 +2648,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2974,6 +3100,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3251,6 +3378,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3329,6 +3457,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3352,6 +3481,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + plans: Default::default(), failed: Vec::new(), demo: false, }; diff --git a/surface.example.toml b/surface.example.toml index 82f8a87..c672eaf 100644 --- a/surface.example.toml +++ b/surface.example.toml @@ -47,10 +47,11 @@ window_days = 30 # Monthly spend per tool, in USD. Keys are tool ids as they appear in the Usage # view: claude_code, codex, opencode, gemini_cli, ... # -# Without an entry here, a tool falls back to the plan its own transcripts name, -# priced at that plan's list rate and marked an estimate. A tool naming no plan -# gets no row at all — surface reads no account state, so a plan it is not told -# about is never guessed. An entry here always wins over a detected plan. +# Without an entry here, a tool falls back to the plan it names itself — in its +# account file (~/.claude.json, ~/.codex/auth.json) or beside the token counts +# in its transcripts — priced at that plan's list rate and marked an estimate. +# Only the plan's name is read from those files, never the credentials beside +# it. A tool naming no plan gets no row at all, and an entry here always wins. [cost.subscriptions] # claude_code = 100.0 # codex = 30.0