@archeagle087/costcrew 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/.github/workflows/npm-release.yml +123 -0
  3. package/.github/workflows/prices-monthly.yml +58 -0
  4. package/.github/workflows/release.yml +45 -0
  5. package/Cargo.lock +980 -0
  6. package/Cargo.toml +22 -0
  7. package/LICENSE +21 -0
  8. package/README.md +255 -2
  9. package/assets/advise.png +0 -0
  10. package/assets/board.png +0 -0
  11. package/assets/catalog.png +0 -0
  12. package/assets/demo.png +0 -0
  13. package/assets/help.png +0 -0
  14. package/assets/mascot.png +0 -0
  15. package/assets/prices.png +0 -0
  16. package/assets/scan.png +0 -0
  17. package/assets/spend.png +0 -0
  18. package/assets/ui.png +0 -0
  19. package/bin.js +46 -0
  20. package/data/prices.json +1 -0
  21. package/dist/costcrew-linux-x86_64.tar.gz +0 -0
  22. package/dist/costcrew-macos-aarch64.tar.gz +0 -0
  23. package/dist/costcrew-macos-x86_64.tar.gz +0 -0
  24. package/dist/costcrew-windows-x86_64.zip +0 -0
  25. package/install.ps1 +38 -0
  26. package/install.sh +56 -0
  27. package/package.json +18 -4
  28. package/packaging/npm/bin.js +46 -0
  29. package/packaging/npm/package.json +20 -0
  30. package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +15 -0
  31. package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +28 -0
  32. package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +5 -0
  33. package/packaging/winget/README.md +31 -0
  34. package/scripts/render_screen.py +221 -0
  35. package/src/advise.rs +154 -0
  36. package/src/args.rs +239 -0
  37. package/src/board.rs +227 -0
  38. package/src/catalog.rs +136 -0
  39. package/src/demo.rs +79 -0
  40. package/src/fetch.rs +155 -0
  41. package/src/main.rs +371 -0
  42. package/src/price.rs +218 -0
  43. package/src/prices.rs +182 -0
  44. package/src/report.rs +194 -0
  45. package/src/scan.rs +593 -0
  46. package/src/spend.rs +452 -0
  47. package/src/ui.rs +367 -0
  48. package/work/costcrew-darwin-arm64/costcrew +0 -0
  49. package/work/costcrew-darwin-arm64/package.json +9 -0
  50. package/work/costcrew-darwin-x64/costcrew +0 -0
  51. package/work/costcrew-darwin-x64/package.json +9 -0
  52. package/work/costcrew-linux-x64/costcrew +0 -0
  53. package/work/costcrew-linux-x64/package.json +9 -0
  54. package/work/costcrew-win32-x64/costcrew.exe +0 -0
  55. package/work/costcrew-win32-x64/package.json +9 -0
package/src/args.rs ADDED
@@ -0,0 +1,239 @@
1
+ //! Simple arg parsing — no clap, keep the binary lean.
2
+
3
+ #[derive(Debug)]
4
+ pub struct Args {
5
+ pub cmd: Cmd,
6
+ pub json: bool,
7
+ pub since_days: u32,
8
+ pub help: bool,
9
+ pub key: Option<String>,
10
+ pub prices: Option<String>,
11
+ pub sort: Option<String>,
12
+ pub top: usize,
13
+ /// positional words after the command ("prices claude", "spend add 20")
14
+ pub pos: Vec<String>,
15
+ pub service: Option<String>,
16
+ pub model: Option<String>,
17
+ pub note: Option<String>,
18
+ pub at: Option<String>,
19
+ }
20
+
21
+ #[derive(Debug, PartialEq)]
22
+ pub enum Cmd {
23
+ Overview,
24
+ Scan,
25
+ Demo,
26
+ Md,
27
+ Advise,
28
+ Catalog,
29
+ Leaderboard,
30
+ OpenRouter,
31
+ Prices,
32
+ Spend,
33
+ SpendAdd,
34
+ Fetch,
35
+ }
36
+
37
+ /// two-word commands, checked before single-word matching
38
+ pub fn split_cmd(argv: &[String]) -> Option<(Cmd, &[String])> {
39
+ if argv.len() >= 2 {
40
+ match (argv[0].as_str(), argv[1].as_str()) {
41
+ ("spend", "add") | ("s", "add") => return Some((Cmd::SpendAdd, &argv[2..])),
42
+ _ => {}
43
+ }
44
+ }
45
+ None
46
+ }
47
+
48
+ pub fn parse(argv: &[String]) -> Result<Args, String> {
49
+ if let Some((sub, rest)) = split_cmd(argv) {
50
+ let mut args = def(sub);
51
+ parse_into(&mut args, rest)?;
52
+ return Ok(args);
53
+ }
54
+ let mut args = def(Cmd::Overview);
55
+ parse_inner(&mut args, argv, &mut false)?; // commands may set cmd; default overview
56
+ if args.help {
57
+ print_help();
58
+ std::process::exit(0);
59
+ }
60
+ Ok(args)
61
+ }
62
+
63
+ fn def(cmd: Cmd) -> Args {
64
+ Args {
65
+ cmd,
66
+ json: false,
67
+ since_days: 30,
68
+ help: false,
69
+ key: None,
70
+ prices: None,
71
+ sort: None,
72
+ top: 25,
73
+ pos: Vec::new(),
74
+ service: None,
75
+ model: None,
76
+ note: None,
77
+ at: None,
78
+ }
79
+ }
80
+
81
+ fn parse_into(args: &mut Args, argv: &[String]) -> Result<(), String> {
82
+ let mut seen_cmd = true; // command already consumed ("spend add X")
83
+ parse_inner(args, argv, &mut seen_cmd)
84
+ }
85
+
86
+ pub fn parse_inner(args: &mut Args, argv: &[String], seen_cmd: &mut bool) -> Result<(), String> {
87
+ let mut it = argv.iter();
88
+ while let Some(a) = it.next() {
89
+ match a.as_str() {
90
+ "prices" | "db" if !*seen_cmd => {
91
+ args.cmd = Cmd::Prices;
92
+ *seen_cmd = true;
93
+ }
94
+ "spend" if !*seen_cmd => {
95
+ args.cmd = Cmd::Spend;
96
+ *seen_cmd = true;
97
+ }
98
+ "scan" | "c" if !*seen_cmd => {
99
+ args.cmd = Cmd::Scan;
100
+ *seen_cmd = true;
101
+ }
102
+ "demo" | "d" if !*seen_cmd => {
103
+ args.cmd = Cmd::Demo;
104
+ *seen_cmd = true;
105
+ }
106
+ "md" if !*seen_cmd => {
107
+ args.cmd = Cmd::Md;
108
+ *seen_cmd = true;
109
+ }
110
+ "advise" | "a" if !*seen_cmd => {
111
+ args.cmd = Cmd::Advise;
112
+ *seen_cmd = true;
113
+ }
114
+ "leaderboard" | "lb" | "board" if !*seen_cmd => {
115
+ args.cmd = Cmd::Leaderboard;
116
+ *seen_cmd = true;
117
+ }
118
+ "catalog" if !*seen_cmd => {
119
+ args.cmd = Cmd::Catalog;
120
+ *seen_cmd = true;
121
+ }
122
+ "openrouter" | "or" if !*seen_cmd => {
123
+ args.cmd = Cmd::OpenRouter;
124
+ *seen_cmd = true;
125
+ }
126
+ "fetch-prices" if !*seen_cmd => {
127
+ args.cmd = Cmd::Fetch;
128
+ *seen_cmd = true;
129
+ }
130
+ "--json" => args.json = true,
131
+ "--sort" => {
132
+ args.sort = Some(it.next().ok_or("--sort needs a value")?.to_string());
133
+ }
134
+ "--top" => {
135
+ let v = it.next().ok_or("--top needs a number")?;
136
+ args.top = v
137
+ .parse()
138
+ .map_err(|_| format!("--top must be a number, got {v}"))?;
139
+ }
140
+ "--service" => {
141
+ args.service = Some(it.next().ok_or("--service needs a value")?.to_string());
142
+ }
143
+ "--model" => {
144
+ args.model = Some(it.next().ok_or("--model needs a value")?.to_string());
145
+ }
146
+ "--note" => {
147
+ args.note = Some(it.next().ok_or("--note needs a value")?.to_string());
148
+ }
149
+ "--at" => {
150
+ args.at = Some(it.next().ok_or("--at needs YYYY-MM-DD")?.to_string());
151
+ }
152
+ "--help" | "-h" | "help" => args.help = true,
153
+ "--version" | "-V" | "-v" => {
154
+ println!("costcrew {}", env!("CARGO_PKG_VERSION"));
155
+ std::process::exit(0);
156
+ }
157
+ "--key" => {
158
+ args.key = Some(it.next().ok_or("--key needs a value")?.to_string());
159
+ }
160
+ "--prices" => {
161
+ args.prices = Some(it.next().ok_or("--prices needs a path")?.to_string());
162
+ }
163
+ "--since" => {
164
+ let v = it.next().ok_or("--since needs a value like 7d")?;
165
+ args.since_days = parse_since(v)?;
166
+ }
167
+ _ => {
168
+ if let Some(rest) = a.strip_prefix("--since=") {
169
+ args.since_days = parse_since(rest)?;
170
+ } else if a.starts_with('-') {
171
+ return Err(format!("unknown argument: {a}\ntry `costcrew --help`"));
172
+ } else if a == "add" && args.cmd == Cmd::Spend {
173
+ // tolerate `costcrew spend add 20` ordering variants
174
+ } else {
175
+ args.pos.push(a.to_string());
176
+ }
177
+ }
178
+ }
179
+ }
180
+ Ok(())
181
+ }
182
+
183
+ fn parse_since(v: &str) -> Result<u32, String> {
184
+ let v = v.trim().to_lowercase();
185
+ let (num, unit) = v.split_at(v.len().saturating_sub(1));
186
+ let n: u32 = num.parse().map_err(|_| format!("bad --since value: {v}"))?;
187
+ match unit {
188
+ "d" => Ok(n.max(1)),
189
+ "w" => Ok(n * 7),
190
+ "m" => Ok(n * 30),
191
+ _ => Err(format!("--since units are d/w/m, got: {v}")),
192
+ }
193
+ }
194
+
195
+ pub fn print_help() {
196
+ println!(
197
+ "costcrew {} — the AI coding price database, plus your spend
198
+
199
+ USAGE — THE DATABASE:
200
+ costcrew opens the interactive app (splash + menu);
201
+ any argument instead uses classic flag mode
202
+ costcrew prices [filter] browse {} models (bundled, offline):
203
+ every input/output/cache rate per 1M tokens
204
+ --sort name|price|cheap --top N --json
205
+ costcrew leaderboard arena Elo ranked with prices:
206
+ --sort elo|value|price --top N --json
207
+ costcrew catalog which agent ships with which models
208
+ costcrew fetch-prices refresh the DB from OpenRouter live
209
+ (cached at %LOCALAPPDATA%\\costcrew)
210
+
211
+ USAGE — YOUR SPEND:
212
+ costcrew spend what YOU pay for AI: monthly totals,
213
+ per-service breakdown (you record it)
214
+ costcrew spend add 19.99 record a payment:
215
+ --service \"Claude Pro\" --model opus
216
+ --note \"monthly\" --at 2026-11-10
217
+ costcrew openrouter actual per-model spend from an API key
218
+ (env OPENROUTER_API_KEY or --key sk-or-v1-...)
219
+
220
+ USAGE — OPTIONAL ESTIMATES:
221
+ costcrew [scan] estimate per-token cost from local agent logs
222
+ costcrew advise could you have used a cheaper model? verdicts
223
+ costcrew md > report.md shareable markdown report
224
+ costcrew demo sample report from synthetic data
225
+
226
+ FLAGS:
227
+ --since <Nd|Nw|Nm> lookback for scan/advise/spend (default 30d)
228
+ --prices <file.json> custom price overrides, e.g. a rented GPU:
229
+ [{{\"prefix\":\"local/llama-3\",\"per_hour\":2.19}}]
230
+ per_hour bills wall-clock (opencode logs the ms)
231
+ --json machine-readable output
232
+ -h, --help this help
233
+
234
+ UNKNOWN PRICES STAY '?':
235
+ the database never invents a number — a free/unpriced model shows ?",
236
+ env!("CARGO_PKG_VERSION"),
237
+ 509,
238
+ );
239
+ }
package/src/board.rs ADDED
@@ -0,0 +1,227 @@
1
+ //! `costcrew leaderboard` — arena-style model ranking, priced.
2
+ //!
3
+ //! Scores: bundled snapshot of LMArena's Coding category (blind pairwise votes,
4
+ //! Bradley-Terry/Elo). Prices: merged from the built-in table + the cached
5
+ //! OpenRouter list (see `costcrew fetch-prices`).
6
+ //!
7
+ //! The value column is tuned for how coding AGENTS actually burn tokens:
8
+ //! agent_blend = 0.2·input + 0.1·output + 0.7·cache_read (per 1M tokens),
9
+ //! from real OpenCode session mixes (cache dwarfs everything in agentic loops).
10
+ //! value = (elo − 1400) / agent_blend → points per dollar.
11
+
12
+ use crate::price::PriceDB;
13
+ use crate::report::{BOLD, DIM, GREEN, RED, RESET, YELLOW};
14
+
15
+ pub struct BoardEntry {
16
+ /// prefix that must resolve through the price DB
17
+ pub prefix: &'static str,
18
+ pub display: &'static str,
19
+ pub elo: f64,
20
+ pub votes: u64,
21
+ }
22
+
23
+ /// Snapshot: LMArena Coding category, Nov 2026 (~2.0M votes). Best-effort row
24
+ /// per model (highest reasoning effort). Elo only — margins are ±4–19 pts.
25
+ #[rustfmt::skip]
26
+ static BOARD: &[BoardEntry] = &[
27
+ BoardEntry { prefix: "gemini-4-carbon", display: "Gemini 4 Carbon · Google", elo: 1564.0, votes: 1069 },
28
+ BoardEntry { prefix: "gemini-4-argon", display: "Gemini 4 Argon · Google", elo: 1553.0, votes: 4440 },
29
+ BoardEntry { prefix: "claude-fable-5-1", display: "Claude Fable 5.1 · Anthropic", elo: 1534.0, votes: 3870 },
30
+ BoardEntry { prefix: "claude-fable-5", display: "Claude Fable 5 · Anthropic", elo: 1551.0, votes: 11686 },
31
+ BoardEntry { prefix: "claude-opus-4-7", display: "Claude Opus 4.7 · Anthropic", elo: 1546.0, votes: 18359 },
32
+ BoardEntry { prefix: "claude-opus-4-6", display: "Claude Opus 4.6 · Anthropic", elo: 1547.0, votes: 24575 },
33
+ BoardEntry { prefix: "claude-opus-4-5", display: "Claude Opus 4.5 · Anthropic", elo: 1530.0, votes: 7752 },
34
+ BoardEntry { prefix: "claude-opus-5-5", display: "Claude Opus 5.5 · Anthropic", elo: 1546.0, votes: 3257 },
35
+ BoardEntry { prefix: "claude-opus-5", display: "Claude Opus 5 · Anthropic", elo: 1532.0, votes: 18838 },
36
+ BoardEntry { prefix: "claude-sonnet-5-5", display: "Claude Sonnet 5.5 · Anthropic", elo: 1517.0, votes: 3109 },
37
+ BoardEntry { prefix: "claude-sonnet-5", display: "Claude Sonnet 5 · Anthropic", elo: 1520.0, votes: 14082 },
38
+ BoardEntry { prefix: "claude-sonnet-4-6", display: "Claude Sonnet 4.6 · Anthropic", elo: 1528.0, votes: 19474 },
39
+ BoardEntry { prefix: "claude-haiku-5-5", display: "Claude Haiku 5.5 · Anthropic", elo: 1455.0, votes: 3003 },
40
+ BoardEntry { prefix: "claude-haiku-4-5", display: "Claude Haiku 4.5 · Anthropic", elo: 1453.0, votes: 39588 },
41
+ BoardEntry { prefix: "gpt-6-astra", display: "GPT-6 Astra · OpenAI", elo: 1548.0, votes: 4096 },
42
+ BoardEntry { prefix: "gpt-6-1-sol", display: "GPT-6.1 Sol · OpenAI", elo: 1541.0, votes: 2668 },
43
+ BoardEntry { prefix: "gpt-6-sol", display: "GPT-6 Sol · OpenAI", elo: 1519.0, votes: 4530 },
44
+ BoardEntry { prefix: "gpt-6-luna", display: "GPT-6 Luna · OpenAI", elo: 1501.0, votes: 24306 },
45
+ BoardEntry { prefix: "gpt-5-6-sol", display: "GPT-5.6 Sol · OpenAI", elo: 1533.0, votes: 11621 },
46
+ BoardEntry { prefix: "gpt-5-5", display: "GPT-5.5 · OpenAI", elo: 1510.0, votes: 19751 },
47
+ BoardEntry { prefix: "gpt-5-4", display: "GPT-5.4 · OpenAI", elo: 1521.0, votes: 17419 },
48
+ BoardEntry { prefix: "gpt-5-2", display: "GPT-5.2 · OpenAI", elo: 1442.0, votes: 12151 },
49
+ BoardEntry { prefix: "muse-wonder", display: "Muse Wonder · Meta", elo: 1546.0, votes: 2780 },
50
+ BoardEntry { prefix: "muse-spark-1-3", display: "Muse Spark 1.3 · Meta", elo: 1532.0, votes: 6575 },
51
+ BoardEntry { prefix: "kimi-k3", display: "Kimi K3 · Moonshot", elo: 1537.0, votes: 10072 },
52
+ BoardEntry { prefix: "kimi-k2-6", display: "Kimi K2.6 · Moonshot", elo: 1516.0, votes: 10982 },
53
+ BoardEntry { prefix: "glm-5-3", display: "GLM-5.3 · Z.ai", elo: 1522.0, votes: 7164 },
54
+ BoardEntry { prefix: "glm-5-3-flash", display: "GLM-5.3-Flash · Z.ai", elo: 1527.0, votes: 12519 },
55
+ BoardEntry { prefix: "glm-5-2", display: "GLM-5.2 · Z.ai", elo: 1515.0, votes: 14375 },
56
+ BoardEntry { prefix: "glm-4-6", display: "GLM 4.6 · Z.ai", elo: 1449.0, votes: 7577 },
57
+ BoardEntry { prefix: "deepseek-v4-1-flash", display: "DeepSeek V4.1 Flash · DeepSeek", elo: 1525.0, votes: 3719 },
58
+ BoardEntry { prefix: "deepseek-v4-pro", display: "DeepSeek V4 Pro · DeepSeek", elo: 1503.0, votes: 2756 },
59
+ BoardEntry { prefix: "qwen3-8-max", display: "Qwen3.8-Max · Alibaba", elo: 1524.0, votes: 9596 },
60
+ BoardEntry { prefix: "qwen3-8-27b", display: "Qwen3.8-27B · Alibaba", elo: 1478.0, votes: 8592 },
61
+ BoardEntry { prefix: "grok-4-6", display: "Grok 4.6 · xAI", elo: 1508.0, votes: 8571 },
62
+ BoardEntry { prefix: "mimo-v2-6-pro", display: "MiMo-V2.6-Pro · Xiaomi", elo: 1538.0, votes: 1095 },
63
+ BoardEntry { prefix: "mimo-v2-6-flash", display: "MiMo-V2.6-Flash · Xiaomi", elo: 1511.0, votes: 5033 },
64
+ BoardEntry { prefix: "gemini-3-8-flash", display: "Gemini 3.8 Flash · Google", elo: 1530.0, votes: 15174 },
65
+ BoardEntry { prefix: "gemini-3-7-flash", display: "Gemini 3.7 Flash · Google", elo: 1520.0, votes: 8973 },
66
+ BoardEntry { prefix: "gemini-3-6-flash", display: "Gemini 3.6 Flash · Google", elo: 1518.0, votes: 10179 },
67
+ BoardEntry { prefix: "gemini-2-5-pro", display: "Gemini 2.5 Pro · Google", elo: 1452.0, votes: 26810 },
68
+ ];
69
+
70
+ /// a representative agentic token blend (fractions of billable tokens)
71
+ const BLEND_IN: f64 = 0.2;
72
+ const BLEND_OUT: f64 = 0.1;
73
+ const BLEND_CACHE: f64 = 0.7;
74
+ const ELO_BASELINE: f64 = 1400.0;
75
+
76
+ fn agent_blend(p: crate::price::Pricing) -> f64 {
77
+ BLEND_IN * p.input + BLEND_OUT * p.output + BLEND_CACHE * p.cache_read
78
+ }
79
+
80
+ #[derive(Clone, Copy, PartialEq)]
81
+ pub enum Sort {
82
+ Elo,
83
+ Value,
84
+ Blend,
85
+ }
86
+
87
+ pub fn parse_sort(s: &str) -> Result<Sort, String> {
88
+ match s {
89
+ "elo" | "score" => Ok(Sort::Elo),
90
+ "value" => Ok(Sort::Value),
91
+ "price" | "cost" | "blend" => Ok(Sort::Blend),
92
+ _ => Err("--sort is elo | value | price".into()),
93
+ }
94
+ }
95
+
96
+ pub fn print_board(db: &PriceDB, sort: Sort, top: usize, json: bool) {
97
+ let mut rows: Vec<(&BoardEntry, Option<f64>, Option<f64>)> = BOARD
98
+ .iter()
99
+ .map(|b| {
100
+ let blend = db.lookup(b.prefix).map(agent_blend);
101
+ let value = blend.map(|blend| (b.elo - ELO_BASELINE) / blend);
102
+ (b, blend, value)
103
+ })
104
+ .collect();
105
+
106
+ match sort {
107
+ Sort::Elo => rows.sort_by(|a, b| {
108
+ b.0.elo
109
+ .partial_cmp(&a.0.elo)
110
+ .unwrap_or(std::cmp::Ordering::Equal)
111
+ }),
112
+ Sort::Value => rows.sort_by(|a, b| {
113
+ b.2.unwrap_or(f64::NEG_INFINITY)
114
+ .partial_cmp(&a.2.unwrap_or(f64::NEG_INFINITY))
115
+ .unwrap_or(std::cmp::Ordering::Equal)
116
+ }),
117
+ Sort::Blend => rows.sort_by(|a, b| {
118
+ a.1.unwrap_or(f64::INFINITY)
119
+ .partial_cmp(&b.1.unwrap_or(f64::INFINITY))
120
+ .unwrap_or(std::cmp::Ordering::Equal)
121
+ }),
122
+ }
123
+
124
+ if json {
125
+ use serde_json::json;
126
+ let arr: Vec<_> = rows
127
+ .iter()
128
+ .take(top)
129
+ .map(|(b, blend, value)| {
130
+ json!({
131
+ "model": b.display.trim(),
132
+ "prefix": b.prefix,
133
+ "arena_elo": b.elo,
134
+ "votes": b.votes,
135
+ "agent_blend_usd_per_1m": blend.map(|x| (x * 100.0).round() / 100.0),
136
+ "value_elo_per_dollar": value.map(|x| (x * 10.0).round() / 10.0),
137
+ })
138
+ })
139
+ .collect();
140
+ println!(
141
+ "{}",
142
+ json!({
143
+ "source": "LMArena Coding snapshot Nov 2026",
144
+ "blend": {"input": BLEND_IN, "output": BLEND_OUT, "cache_read": BLEND_CACHE},
145
+ "rows": arr,
146
+ })
147
+ );
148
+ return;
149
+ }
150
+
151
+ println!();
152
+ println!(" {BOLD}costcrew leaderboard{RESET} {DIM}· LMArena Coding snapshot Nov 2026 · ~2.0M blind votes{RESET}");
153
+ println!();
154
+ println!(
155
+ " {:>3} {:<30} {:>5} {:>7} {:>8} {:>8} {:>9} {:>10}",
156
+ "#", "model", "elo", "votes", "in $", "out $", "agent$", "elo/$"
157
+ );
158
+ println!(
159
+ " {}",
160
+ "-".repeat(3 + 1 + 30 + 1 + 5 + 1 + 7 + 1 + 8 + 1 + 8 + 1 + 9 + 1 + 10)
161
+ );
162
+
163
+ for (i, (b, blend, value)) in rows.iter().take(top).enumerate() {
164
+ let blend = *blend;
165
+ let value = *value;
166
+ let (bi, bo) = match blend {
167
+ Some(_) => {
168
+ let p = db.lookup(b.prefix).expect("blend implies price");
169
+ (p.input, p.output)
170
+ }
171
+ None => (f64::NAN, f64::NAN),
172
+ };
173
+ let price_str = |v: f64| {
174
+ if blend.is_some() {
175
+ format!("{:>7.2}", v)
176
+ } else {
177
+ " ?".to_string()
178
+ }
179
+ };
180
+ let blend_str = match blend {
181
+ Some(blend) => format!("{:.2}", blend),
182
+ None => "?".into(),
183
+ };
184
+ let value_str = match value {
185
+ Some(v) if v.is_finite() => format!("{:.1}", v),
186
+ _ => "-".into(),
187
+ };
188
+ let color = match value {
189
+ Some(v) if blend.is_some_and(|blend| blend > 0.0) && v >= 25.0 => GREEN,
190
+ Some(v) if blend.is_some_and(|blend| blend > 0.0) && v >= 8.0 => YELLOW,
191
+ _ if value.is_some() => RED,
192
+ _ => DIM,
193
+ };
194
+ println!(
195
+ " {:>3} {:<30} {:>5.0} {:>7} {} {} {:>9} {color}{:>10}{RESET}",
196
+ i + 1,
197
+ b.display,
198
+ b.elo,
199
+ format_votes(b.votes),
200
+ price_str(bi),
201
+ price_str(bo),
202
+ blend_str,
203
+ value_str,
204
+ color = color,
205
+ );
206
+ }
207
+
208
+ if rows.iter().all(|(_, _, _)| true) && rows.iter().any(|(_, blend, _)| blend.is_none()) {
209
+ println!();
210
+ println!(" {DIM}? = model not in your price sources yet. `costcrew fetch-prices` may fix it.{RESET}");
211
+ }
212
+ println!();
213
+ println!(
214
+ " {DIM}agent$ = typical agentic blend: 20% in + 10% out + 70% cache-read (per 1M).{RESET}"
215
+ );
216
+ println!(" {DIM}elo/$ = (elo − 1400) ÷ agent$ — value points per dollar{RESET}");
217
+ println!(" {DIM}snapshot: arena.ai / lmarena.ai — bump `src/board.rs` with a PR{RESET}");
218
+ println!();
219
+ }
220
+
221
+ fn format_votes(v: u64) -> String {
222
+ if v >= 1000 {
223
+ format!("{:.1}k", v as f64 / 1000.0)
224
+ } else {
225
+ format!("{v}")
226
+ }
227
+ }
package/src/catalog.rs ADDED
@@ -0,0 +1,136 @@
1
+ //! `costcrew catalog` — what every coding agent can run on, and how it bills.
2
+
3
+ use crate::report::{BOLD, CYAN, DIM, RESET};
4
+
5
+ pub struct Agent {
6
+ pub name: &'static str,
7
+ /// how it bills
8
+ pub billing: &'static str,
9
+ /// can it run on arbitrary API keys?
10
+ pub byok: &'static str,
11
+ /// whether costcrew can read its logs
12
+ pub scanned: &'static str,
13
+ pub models: &'static str,
14
+ pub url: &'static str,
15
+ }
16
+
17
+ const CATALOG: &[Agent] = &[
18
+ Agent {
19
+ name: "Claude Code",
20
+ billing: "Pro/Max subscription (weekly caps) OR pay-per-token API",
21
+ byok: "yes (Anthropic key)",
22
+ scanned: "yes (JSONL transcripts)",
23
+ models: "claude-opus / sonnet / haiku (all tiers)",
24
+ url: "docs.anthropic.com/claude-code",
25
+ },
26
+ Agent {
27
+ name: "OpenCode",
28
+ billing: "free, open source; you pay the model",
29
+ byok: "yes — Anthropic, OpenAI, Google, OpenRouter, GLM, local",
30
+ scanned: "yes (sqlite + JSON)",
31
+ models: "any provider; public models include big-pickle (free tier)",
32
+ url: "opencode.ai",
33
+ },
34
+ Agent {
35
+ name: "Codex CLI",
36
+ billing: "bundled with ChatGPT Plus/Pro; API per-token otherwise",
37
+ byok: "OpenAI key",
38
+ scanned: "best-effort (JSONL)",
39
+ models: "gpt-5 codex line (fast/slow lanes)",
40
+ url: "github.com/openai/codex",
41
+ },
42
+ Agent {
43
+ name: "Gemini CLI",
44
+ billing: "free tier ~1,000 req/day with Google login; Vertex/Studio per-token",
45
+ byok: "yes (GCP key)",
46
+ scanned: "not yet",
47
+ models: "gemini 3 / 2 nano",
48
+ url: "github.com/google-gemini/gemini-cli",
49
+ },
50
+ Agent {
51
+ name: "GitHub Copilot CLI",
52
+ billing: "Copilot Pro/Pro+ subscription; some models bill per premium-request",
53
+ byok: "no",
54
+ scanned: "not yet",
55
+ models: "gpt + claude + gemini, managed by GitHub policy",
56
+ url: "github.com/features/copilot",
57
+ },
58
+ Agent {
59
+ name: "Kilo Code",
60
+ billing: "extension, free; pay via OpenRouter or your own API keys",
61
+ byok: "yes (OpenRouter recommended)",
62
+ scanned: "yes-best-effort (task JSON)",
63
+ models: "anything on OpenRouter incl. free deepseek/qwen lanes",
64
+ url: "kilocode.ai",
65
+ },
66
+ Agent {
67
+ name: "Cline",
68
+ billing: "extension, free; bring API keys (incl. Anthropic/OpenRouter)",
69
+ byok: "yes — per-task cost shown in-app",
70
+ scanned: "yes-best-effort (task JSON)",
71
+ models: "any; plus auto-mode model picking",
72
+ url: "cline.bot",
73
+ },
74
+ Agent {
75
+ name: "Roo Code",
76
+ billing: "extension, free; BYOK",
77
+ byok: "yes; models scoped per mode (ask/code/architect)",
78
+ scanned: "yes-best-effort (task JSON)",
79
+ models: "anything through the major providers",
80
+ url: "roocode.com",
81
+ },
82
+ Agent {
83
+ name: "Aider",
84
+ billing: "open source CLI; pay per API token; total printed per commit",
85
+ byok: "yes — literally any openai-compatible endpoint incl. local Ollama",
86
+ scanned: "yes (Cost lines in .aider.chat.history.md)",
87
+ models: "any; pairs well with deepseek-chat",
88
+ url: "aider.chat",
89
+ },
90
+ Agent {
91
+ name: "Cursor",
92
+ billing: "Pro subscription ($20) + usage-based add-ons for frontier models",
93
+ byok: "limited",
94
+ scanned: "not yet",
95
+ models: "bundled: claude, gpt, gemini, cursor's own mini models",
96
+ url: "cursor.com",
97
+ },
98
+ Agent {
99
+ name: "Windsurf",
100
+ billing: "sub plans with monthly credit pool; overage billed",
101
+ byok: "yes on some tiers",
102
+ scanned: "not yet",
103
+ models: "bundled claude/gpt + their ide models",
104
+ url: "windsurf.com",
105
+ },
106
+ Agent {
107
+ name: "self-hosted (vLLM/ollama/llama.cpp)",
108
+ billing: "GPU rental from $1–3/hr (cloud) to free (local); electricity",
109
+ byok: "n/a — it IS the server",
110
+ scanned: "not yet (roadmap: opencode sessions cover it)",
111
+ models: "llama, qwen, deepseek, glm, mistral — open weights",
112
+ url: "docs.vllm.ai",
113
+ },
114
+ ];
115
+
116
+ pub fn print_catalog() {
117
+ println!();
118
+ println!(
119
+ " {BOLD}costcrew catalog{RESET} {DIM}· who runs what · list-price snapshot mid-2026{RESET}"
120
+ );
121
+ println!();
122
+
123
+ for a in CATALOG {
124
+ println!(" {CYAN}{BOLD}{:<33}{RESET}", a.name);
125
+ println!(" {DIM}billing :{RESET} {}", a.billing);
126
+ println!(" {DIM}models :{RESET} {}", a.models);
127
+ println!(" {DIM}byok :{RESET} {}", a.byok);
128
+ println!(" {DIM}scanned :{RESET} {}", a.scanned);
129
+ println!(" {DIM}link :{RESET} {}", a.url);
130
+ println!();
131
+ }
132
+ println!(
133
+ " {DIM}got a lane we're missing? open an issue: github.com/Arch087146/costcrew{RESET}"
134
+ );
135
+ println!();
136
+ }
package/src/demo.rs ADDED
@@ -0,0 +1,79 @@
1
+ //! Synthetic data so users can see the report before they've used any agent.
2
+
3
+ use crate::scan::{aggregate, Row, UsageEvent};
4
+
5
+ /// Roughly realistic fake week of usage.
6
+ pub fn demo_events() -> Vec<UsageEvent> {
7
+ fn ev(provider: &str, model: &str, i: u64, o: u64, cr: u64, cw: u64) -> UsageEvent {
8
+ UsageEvent {
9
+ provider: provider.into(),
10
+ model: model.into(),
11
+ input_tokens: i,
12
+ output_tokens: o,
13
+ cache_read: cr,
14
+ cache_write: cw,
15
+ duration_ms: 0,
16
+ direct_cost: None,
17
+ source: "demo".into(),
18
+ }
19
+ }
20
+
21
+ vec![
22
+ // a heavy week of Claude Code refactoring
23
+ ev(
24
+ "claude",
25
+ "claude-opus-4-5-20260115",
26
+ 40_000,
27
+ 9_000,
28
+ 2_100_000,
29
+ 310_000,
30
+ ),
31
+ ev(
32
+ "claude",
33
+ "claude-opus-4-5-20260115",
34
+ 31_000,
35
+ 7_400,
36
+ 1_850_000,
37
+ 280_000,
38
+ ),
39
+ ev(
40
+ "claude",
41
+ "claude-sonnet-4-5",
42
+ 22_000,
43
+ 5_100,
44
+ 980_000,
45
+ 140_000,
46
+ ),
47
+ ev(
48
+ "claude",
49
+ "claude-sonnet-4-5",
50
+ 18_500,
51
+ 4_300,
52
+ 870_000,
53
+ 120_000,
54
+ ),
55
+ ev(
56
+ "claude",
57
+ "claude-sonnet-4-5",
58
+ 15_000,
59
+ 3_800,
60
+ 760_000,
61
+ 98_000,
62
+ ),
63
+ // OpenCode doing feature work on GLM
64
+ ev("opencode", "glm-5", 48_000, 12_000, 240_000, 60_000),
65
+ ev("opencode", "glm-5", 39_000, 9_800, 190_000, 44_000),
66
+ ev("opencode", "glm-5", 27_000, 6_900, 130_000, 31_000),
67
+ // Codex quick sessions
68
+ ev("codex", "gpt-5.2-codex", 12_000, 2_400, 410_000, 80_000),
69
+ ev("codex", "gpt-5.2-codex", 8_000, 1_600, 260_000, 51_000),
70
+ // deepseek via an API key (Kilo Code / OpenRouter lane)
71
+ ev("kilo", "deepseek-chat", 33_000, 7_200, 640_000, 96_000),
72
+ // one model we don't have a price for
73
+ ev("opencode", "mystery-coder-xl", 30_000, 8_000, 0, 10_000),
74
+ ]
75
+ }
76
+
77
+ pub fn demo_rows(db: &super::price::PriceDB) -> Vec<Row> {
78
+ aggregate(&demo_events(), db)
79
+ }