@archeagle087/costcrew 0.4.0 → 0.4.2

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 (53) hide show
  1. package/bin.js +12 -5
  2. package/package.json +8 -5
  3. package/.github/workflows/ci.yml +0 -21
  4. package/.github/workflows/npm-release.yml +0 -107
  5. package/.github/workflows/prices-monthly.yml +0 -58
  6. package/.github/workflows/release.yml +0 -45
  7. package/Cargo.lock +0 -980
  8. package/Cargo.toml +0 -22
  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/data/prices.json +0 -1
  20. package/dist/costcrew-linux-x86_64.tar.gz +0 -0
  21. package/dist/costcrew-macos-aarch64.tar.gz +0 -0
  22. package/dist/costcrew-macos-x86_64.tar.gz +0 -0
  23. package/dist/costcrew-windows-x86_64.zip +0 -0
  24. package/install.ps1 +0 -38
  25. package/install.sh +0 -56
  26. package/packaging/npm/bin.js +0 -43
  27. package/packaging/npm/package.json +0 -20
  28. package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +0 -15
  29. package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +0 -28
  30. package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +0 -5
  31. package/packaging/winget/README.md +0 -31
  32. package/scripts/render_screen.py +0 -221
  33. package/src/advise.rs +0 -154
  34. package/src/args.rs +0 -239
  35. package/src/board.rs +0 -227
  36. package/src/catalog.rs +0 -136
  37. package/src/demo.rs +0 -79
  38. package/src/fetch.rs +0 -155
  39. package/src/main.rs +0 -371
  40. package/src/price.rs +0 -218
  41. package/src/prices.rs +0 -182
  42. package/src/report.rs +0 -194
  43. package/src/scan.rs +0 -593
  44. package/src/spend.rs +0 -452
  45. package/src/ui.rs +0 -367
  46. package/work/costcrew-darwin-arm64/costcrew +0 -0
  47. package/work/costcrew-darwin-arm64/package.json +0 -9
  48. package/work/costcrew-darwin-x64/costcrew +0 -0
  49. package/work/costcrew-darwin-x64/package.json +0 -9
  50. package/work/costcrew-linux-x64/costcrew +0 -0
  51. package/work/costcrew-linux-x64/package.json +0 -9
  52. package/work/costcrew-win32-x64/costcrew.exe +0 -0
  53. package/work/costcrew-win32-x64/package.json +0 -9
package/src/spend.rs DELETED
@@ -1,452 +0,0 @@
1
- //! `costcrew spend` — YOUR AI spending, as recorded by you.
2
- //!
3
- //! The tool never guesses here: you record what you actually paid
4
- //! (subscription renewals, API credits, GPU rentals) and costcrew turns the
5
- //! ledger into monthly totals and per-service breakdowns. It deliberately
6
- //! complements the estimates from `scan` and the provider-truth from
7
- //! `openrouter`: this is the number that leaves your bank account.
8
- //!
9
- //! Ledger: %LOCALAPPDATA%\costcrew\spend.json. No network. Nothing leaves disk.
10
-
11
- use crate::fetch::cache_dir;
12
- use crate::report::{BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW};
13
- use serde_json::Value;
14
- use std::time::{Duration, SystemTime};
15
-
16
- #[derive(Debug)]
17
- pub struct Entry {
18
- /// YYYY-MM-DD the spend happened (defaults to today)
19
- pub date: String,
20
- pub amount: f64,
21
- pub service: String,
22
- pub model: String,
23
- pub note: String,
24
- }
25
-
26
- // ---------- ledger IO ----------
27
-
28
- fn ledger_path() -> std::path::PathBuf {
29
- cache_dir().join("spend.json")
30
- }
31
-
32
- pub fn all_entries() -> Vec<Entry> {
33
- load()
34
- }
35
-
36
- fn load() -> Vec<Entry> {
37
- let Ok(text) = std::fs::read_to_string(ledger_path()) else {
38
- return Vec::new();
39
- };
40
- let Ok(v) = serde_json::from_str::<Value>(&text) else {
41
- return Vec::new();
42
- };
43
- v.get("entries")
44
- .and_then(Value::as_array)
45
- .map(|list| {
46
- list.iter()
47
- .filter_map(|e| {
48
- Some(Entry {
49
- date: e.get("date").and_then(Value::as_str)?.to_string(),
50
- amount: e.get("amount").and_then(Value::as_f64)?,
51
- service: e
52
- .get("service")
53
- .and_then(Value::as_str)
54
- .unwrap_or("unspecified")
55
- .to_string(),
56
- model: e
57
- .get("model")
58
- .and_then(Value::as_str)
59
- .unwrap_or("")
60
- .to_string(),
61
- note: e
62
- .get("note")
63
- .and_then(Value::as_str)
64
- .unwrap_or("")
65
- .to_string(),
66
- })
67
- })
68
- .collect()
69
- })
70
- .unwrap_or_default()
71
- }
72
-
73
- fn save(entries: &[Entry]) -> Result<(), String> {
74
- let doc = serde_json::json!({
75
- "version": 1,
76
- "entries": entries.iter().map(|e| serde_json::json!({
77
- "date": e.date, "amount": e.amount,
78
- "service": e.service, "model": e.model, "note": e.note,
79
- })).collect::<Vec<_>>(),
80
- });
81
- let dir = cache_dir();
82
- std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
83
- std::fs::write(ledger_path(), doc.to_string()).map_err(|e| e.to_string())
84
- }
85
-
86
- // ---------- public commands ----------
87
-
88
- /// `costcrew spend add <amount>` — record one payment.
89
- pub fn add(args: &crate::args::Args) {
90
- let amount = match args.pos.first().map(|a| parse_amount(a)) {
91
- Some(Ok(a)) => a,
92
- Some(Err(e)) => {
93
- eprintln!("error: {e}");
94
- std::process::exit(2);
95
- }
96
- None => {
97
- eprintln!("error: spend add needs an amount, e.g. costcrew spend add 19.99");
98
- eprintln!(" flags: --service \"Claude Pro\" --model opus --note \"monthly\"");
99
- std::process::exit(2);
100
- }
101
- };
102
- let date = args.at.clone().unwrap_or_else(today);
103
- match push_entry(Entry {
104
- date,
105
- amount,
106
- service: args.service.clone().unwrap_or_else(|| "unspecified".into()),
107
- model: args.model.clone().unwrap_or_default(),
108
- note: args.note.clone().unwrap_or_default(),
109
- }) {
110
- Ok(n) => println!(
111
- " {GREEN}✔{RESET} recorded ${amount:.2} — {DIM}{} entries in the ledger{RESET}",
112
- n
113
- ),
114
- Err(e) => {
115
- eprintln!("error: can't write ledger: {e}");
116
- std::process::exit(1);
117
- }
118
- }
119
- }
120
-
121
- /// shared writer for the flag path and the interactive path
122
- pub fn push_entry(e: Entry) -> Result<usize, String> {
123
- let mut entries = load();
124
- entries.push(e);
125
- save(&entries)?;
126
- Ok(entries.len())
127
- }
128
-
129
- /// menu-driven version of `spend add` — plain prompts, no flags
130
- pub fn interactive_add() {
131
- println!();
132
- println!(
133
- " {BOLD}record a payment{RESET} {DIM}· goes to {}{RESET}",
134
- ledger_path().display()
135
- );
136
- println!();
137
- let amount = match parse_amount(&prompt_of("amount $:")) {
138
- Ok(a) => a,
139
- Err(e) => {
140
- println!(" {RED}✘ {e}{RESET}");
141
- return;
142
- }
143
- };
144
- let service = {
145
- let s = prompt_of("service (e.g. Claude Pro):");
146
- if s.is_empty() {
147
- "unspecified".into()
148
- } else {
149
- s
150
- }
151
- };
152
- let model = prompt_of("model (enter to skip):");
153
- let note = prompt_of("note (enter to skip):");
154
- match push_entry(Entry {
155
- date: today(),
156
- amount,
157
- service,
158
- model,
159
- note,
160
- }) {
161
- Ok(_) => println!(" {GREEN}✔{RESET} recorded ${amount:.2}"),
162
- Err(e) => println!(" {RED}✘ can't write ledger: {e}{RESET}"),
163
- }
164
- println!();
165
- }
166
-
167
- fn prompt_of(label: &str) -> String {
168
- use std::io::Write;
169
- print!(" {CYAN}{label}{RESET} ");
170
- std::io::stdout().flush().ok();
171
- let mut s = String::new();
172
- std::io::stdin().read_line(&mut s).ok();
173
- s.trim().to_string()
174
- }
175
-
176
- /// `costcrew spend` — the summary view.
177
- pub fn print_spend(json: bool, lookback_days: u32) {
178
- let entries = load();
179
- let today = today();
180
-
181
- if json {
182
- use serde_json::json;
183
- let month_tot = total_in_month(&entries, &month_of(&today));
184
- let rolling = total_last_n_days(&entries, &today, lookback_days);
185
- let all: f64 = entries.iter().map(|e| e.amount).sum();
186
- let arr: Vec<_> = entries
187
- .iter()
188
- .map(|e| {
189
- json!({
190
- "date": e.date, "amount": e.amount, "service": e.service,
191
- "model": e.model, "note": e.note,
192
- })
193
- })
194
- .collect();
195
- println!(
196
- "{}",
197
- json!({
198
- "this_month_usd": (month_tot * 100.0).round() / 100.0,
199
- "last_n_days_usd": ((rolls_into(month_tot, rolling)) * 100.0).round() / 100.0,
200
- "all_time_usd": (all * 100.0).round() / 100.0,
201
- "entries": arr,
202
- })
203
- );
204
- return;
205
- }
206
-
207
- println!();
208
- println!(
209
- " {BOLD}costcrew spend{RESET} {DIM}· your AI cash outlay (only what you recorded){RESET}"
210
- );
211
- println!();
212
-
213
- if entries.is_empty() {
214
- println!(" {YELLOW}nothing recorded yet.{RESET}");
215
- println!();
216
- println!(" {DIM}examples:{RESET}");
217
- println!(" {CYAN}costcrew spend add 20 --service \"Claude Pro\"{RESET}");
218
- println!(" {CYAN}costcrew spend add 25 --service \"OpenRouter\" --note \"credit top-up\"{RESET}");
219
- println!(" {CYAN}costcrew spend add 1.84 --service \"Vast.ai\" --model llama-70b --note \"2h @ $0.92/h\"{RESET}");
220
- println!();
221
- println!(
222
- " {DIM}this ledger is what actually leaves your bank — `{CYAN}scan{DIM}` estimates\n per-token usage, `{CYAN}openrouter{DIM}` reports key spend, this records the real bills.{RESET}"
223
- );
224
- println!();
225
- return;
226
- }
227
-
228
- let this_month = total_in_month(&entries, &month_of(&today));
229
- let rolling = total_last_n_days(&entries, &today, lookback_days);
230
- let all_time: f64 = entries.iter().map(|e| e.amount).sum();
231
-
232
- println!(
233
- " {DIM}last {}d{RESET} {BOLD}${:>8.2}{RESET} {DIM}this month{RESET} {BOLD}${:>8.2}{RESET} {DIM}all-time{RESET} {BOLD}${:>8.2}{RESET}",
234
- lookback_days, rolling.min(this_month.max(rolling)), this_month, all_time
235
- );
236
-
237
- // by service — last N days
238
- let mut by_service: Vec<(String, f64)> = Vec::new();
239
- for e in &entries {
240
- if !in_last_n_days(&e.date, &today, lookback_days) {
241
- continue;
242
- }
243
- match by_service.iter_mut().find(|(s, _)| *s == e.service) {
244
- Some((_, v)) => *v += e.amount,
245
- None => by_service.push((e.service.clone(), e.amount)),
246
- }
247
- }
248
- by_service.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
249
- if !by_service.is_empty() {
250
- println!();
251
- println!(" {DIM}by service · last {}d{RESET}", lookback_days);
252
- let max = by_service.iter().fold(0.0f64, |m, (_, v)| m.max(*v));
253
- for (s, v) in &by_service {
254
- let bar = bar(*v, max);
255
- let col = value_color(*v, max);
256
- println!(" {:<18} {col}${:>7.2}{RESET} {bar}", truncate(s, 18), v);
257
- }
258
- }
259
-
260
- // by month, rolling 6
261
- let mut by_month: Vec<(String, f64)> = Vec::new();
262
- for e in &entries {
263
- let m = month_of(&e.date);
264
- match by_month.iter_mut().find(|(s, _)| *s == m) {
265
- Some((_, v)) => *v += e.amount,
266
- None => by_month.push((m, e.amount)),
267
- }
268
- }
269
- by_month.sort_by(|a, b| a.0.cmp(&b.0));
270
- let by_month = &by_month[by_month.len().saturating_sub(6)..];
271
- if !by_month.is_empty() {
272
- println!();
273
- println!(" {DIM}by month{RESET}");
274
- let max = by_month.iter().fold(0.0f64, |m, (_, v)| m.max(*v));
275
- for (m, v) in by_month {
276
- let bar = bar(*v, max);
277
- let mad = if *m == month_of(&today) { BOLD } else { "" };
278
- println!(" {mad}{m}{RESET} {RED}${:>7.2}{RESET} {bar}", v);
279
- }
280
- }
281
-
282
- println!();
283
- println!(
284
- " {DIM}estimate of the same window per-token: {RESET}{CYAN}costcrew scan{RESET}{DIM} · provider truth: {RESET}{CYAN}costcrew openrouter{RESET}{DIM}{RESET}"
285
- );
286
- println!();
287
- }
288
-
289
- fn rolls_into(_a: f64, b: f64) -> f64 {
290
- b
291
- }
292
-
293
- fn value_color(v: f64, max: f64) -> &'static str {
294
- let frac = if max > 0.0 { v / max } else { 0.0 };
295
- if frac >= 0.66 {
296
- RED
297
- } else if frac >= 0.33 {
298
- YELLOW
299
- } else {
300
- GREEN
301
- }
302
- }
303
-
304
- fn bar(v: f64, max: f64) -> String {
305
- if max <= 0.0 {
306
- return String::new();
307
- }
308
- let n = ((v / max) * 40.0).round() as usize;
309
- format!("{:<40}", "█".repeat(n))
310
- }
311
-
312
- fn truncate(s: &str, n: usize) -> String {
313
- if s.chars().count() <= n {
314
- s.to_string()
315
- } else {
316
- s.chars().take(n - 1).collect::<String>() + "…"
317
- }
318
- }
319
-
320
- /// "$1,234.56" / "19.99" / "0" → f64
321
- pub fn parse_amount(s: &str) -> Result<f64, String> {
322
- let cleaned: String = s
323
- .chars()
324
- .filter(|c| !matches!(c, '$' | ',' | ' '))
325
- .collect();
326
- cleaned
327
- .parse::<f64>()
328
- .map_err(|_| format!("not a dollar amount: {s}"))
329
- .and_then(|v| {
330
- if v.is_finite() && v >= 0.0 {
331
- Ok(v)
332
- } else {
333
- Err(format!("amount must be ≥ 0, got {s}"))
334
- }
335
- })
336
- }
337
-
338
- /// weekday-aware date helpers without chrono: days-from-civil (Hinnant)
339
- fn days_from_civil(date: &str) -> i64 {
340
- let parts: Vec<_> = date
341
- .split('-')
342
- .filter_map(|p| p.parse::<i64>().ok())
343
- .collect();
344
- if parts.len() != 3 {
345
- return i64::MIN;
346
- }
347
- let (y, m, d) = (parts[0], parts[1], parts[2]);
348
- let y = if m <= 2 { y - 1 } else { y };
349
- let era = y.div_euclid(400);
350
- let yoe = y - era * 400;
351
- let mp = (m - 3).rem_euclid(12);
352
- let doy = (153 * mp + 2) / 5 + d - 1;
353
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
354
- era * 146_097 + doe
355
- }
356
-
357
- pub fn today() -> String {
358
- let ms = SystemTime::now()
359
- .duration_since(SystemTime::UNIX_EPOCH)
360
- .map(|d: Duration| d.as_millis() as u64)
361
- .unwrap_or(0);
362
- let z = (ms / 86_400_000) as i64 + 719_468;
363
- let era = z.div_euclid(146_097);
364
- let doe = z.rem_euclid(146_097);
365
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
366
- let mut y = yoe + era * 400;
367
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
368
- let mp = (5 * doy + 2) / 153;
369
- let d = doy - (153 * mp + 2) / 5 + 1;
370
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
371
- if m <= 2 {
372
- y += 1;
373
- }
374
- format!("{y:04}-{m:02}-{d:02}")
375
- }
376
-
377
- fn month_of(date: &str) -> String {
378
- date.chars().take(7).collect()
379
- }
380
-
381
- fn in_last_n_days(date: &str, today: &str, n: u32) -> bool {
382
- let (d, t) = (days_from_civil(date), days_from_civil(today));
383
- if d < i64::MAX / 2 {
384
- t >= d && (t - d) < n as i64
385
- } else {
386
- false
387
- }
388
- }
389
-
390
- fn total_in_month(entries: &[Entry], month: &str) -> f64 {
391
- entries
392
- .iter()
393
- .filter(|e| month_of(&e.date) == month)
394
- .map(|e| e.amount)
395
- .sum()
396
- }
397
-
398
- fn total_last_n_days(entries: &[Entry], today: &str, n: u32) -> f64 {
399
- entries
400
- .iter()
401
- .filter(|e| in_last_n_days(&e.date, today, n))
402
- .map(|e| e.amount)
403
- .sum()
404
- }
405
-
406
- #[cfg(test)]
407
- mod tests {
408
- use super::*;
409
-
410
- #[test]
411
- fn amounts_decode() {
412
- assert_eq!(parse_amount("19.99").unwrap(), 19.99);
413
- assert_eq!(parse_amount("$1,234.50").unwrap(), 1234.50);
414
- assert_eq!(parse_amount("0").unwrap(), 0.0);
415
- assert!(parse_amount("abc").is_err());
416
- assert!(parse_amount("-5").is_err());
417
- }
418
-
419
- #[test]
420
- fn civil_days_are_monotonic() {
421
- // sanity: crossing a month/year boundary keeps ordering
422
- let a = days_from_civil("2026-11-09");
423
- let b = days_from_civil("2026-11-10");
424
- let c = days_from_civil("2027-01-01");
425
- assert!(a < b && b < c);
426
- // ...and is consistent with days_from_civil convention on a known pair:
427
- // 2024-02-29 must exist after 2024-02-28 (leap day)
428
- assert!(days_from_civil("2024-02-28") < days_from_civil("2024-02-29"));
429
- }
430
-
431
- #[test]
432
- fn month_windows() {
433
- let e = |d: &str, a: f64| Entry {
434
- date: d.into(),
435
- amount: a,
436
- service: "test".into(),
437
- model: String::new(),
438
- note: String::new(),
439
- };
440
- let today = "2026-11-10";
441
- let entries = vec![
442
- e("2026-11-01", 20.0),
443
- e("2026-10-15", 100.0),
444
- e("2026-11-09", 5.5),
445
- ];
446
- assert_eq!(total_in_month(&entries, "2026-11"), 25.5);
447
- assert_eq!(total_in_month(&entries, "2026-10"), 100.0);
448
- assert_eq!(total_last_n_days(&entries, today, 30), 125.5); // Oct15 = 26d back, inside
449
- // 25-day window: back to Oct 16 → October entry excluded
450
- assert_eq!(total_last_n_days(&entries, today, 25), 25.5);
451
- }
452
- }