@archeagle087/costcrew 0.4.1 → 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 +10 -6
  2. package/package.json +8 -5
  3. package/.github/workflows/ci.yml +0 -21
  4. package/.github/workflows/npm-release.yml +0 -123
  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 -46
  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
@@ -1,221 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Render costcrew output to a terminal-style PNG screenshot.
3
-
4
- Parses the ANSI sequences costcrew actually emits (so screenshots are always
5
- genuine), draws them in Consolas on a terminal window with chrome.
6
-
7
- Usage:
8
- python scripts/render_screen.py assets/demo.png "costcrew demo" -- demo
9
- python scripts/render_screen.py assets/scan.png "costcrew scan --since 30d" -- scan --since 30d
10
- python scripts/render_screen.py assets/help.png "costcrew --help" -- --help
11
-
12
- Commit the PNGs under assets/ — they are pure program output, no hand edits.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import re
18
- import subprocess
19
- import sys
20
- from pathlib import Path
21
-
22
- from PIL import Image, ImageDraw, ImageFont
23
-
24
- ROOT = Path(__file__).resolve().parent.parent
25
- EXE = ROOT / "target" / "release" / "costcrew.exe"
26
-
27
- FG = (230, 237, 243)
28
- DIM = (139, 148, 158)
29
- COLORS = {31: (240, 87, 79), 32: (63, 185, 80), 33: (210, 153, 34), 36: (121, 192, 255)}
30
- BRIGHT = {31: (255, 123, 114), 32: (86, 211, 100), 33: (227, 179, 65)}
31
- BG = (13, 17, 23)
32
- BAR_BG = (30, 34, 43)
33
- DOTS = [(255, 95, 87), (254, 188, 46), (40, 200, 64)]
34
-
35
- # xterm 256-color palette for 38;5;N codes (banner gradient etc.)
36
- _BASE16 = [
37
- (0, 0, 0), (205, 49, 49), (63, 185, 80), (210, 153, 34),
38
- (121, 192, 255), (188, 63, 188), (97, 214, 214), (230, 237, 243),
39
- (139, 148, 158), (255, 123, 114), (86, 211, 100), (227, 179, 65),
40
- (163, 217, 255), (255, 128, 255), (154, 237, 237), (255, 255, 255),
41
- ]
42
- _STEPS = [0, 95, 135, 175, 215, 255]
43
-
44
- def _pal256(n: int) -> tuple[int, int, int]:
45
- if n < 16:
46
- return _BASE16[n]
47
- if n < 232:
48
- n -= 16
49
- r, g, b = (n // 36) % 6, (n // 6) % 6, n % 6
50
- return (_STEPS[r], _STEPS[g], _STEPS[b])
51
- g = 8 + (n - 232) * 10
52
- return (g, g, g)
53
-
54
- ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m")
55
-
56
- Cell = tuple[tuple[int, int, int], bool, str] # (color, bold, char)
57
-
58
-
59
- def tokenize_cli(cli_text: str) -> list[Cell]:
60
- """Tokenizer for a plain (non-ANSI) command line: whole command in bright FG."""
61
- return [(FG, True, ch) for ch in cli_text]
62
-
63
-
64
- def tokenize_ansi(text: str) -> list[Cell]:
65
- """Tokenize ANSI text: returns (color, bold, char), newline chars included."""
66
- out: list[Cell] = []
67
- color = FG
68
- bold = False
69
- pos = 0
70
- for m in ANSI_RE.finditer(text):
71
- for ch in text[pos : m.start()]:
72
- out.append((color, bold, ch))
73
- pos = m.end()
74
- codes = [int(x) for x in m.group(1).split(";") if x != ""]
75
- i = 0
76
- while i < len(codes):
77
- c = codes[i]
78
- if c == 38 and i + 2 < len(codes) and codes[i + 1] == 5:
79
- color = _pal256(codes[i + 2])
80
- i += 3
81
- continue
82
- if c == 0:
83
- color, bold = FG, False
84
- elif c == 1:
85
- bold = True
86
- elif c == 2:
87
- color = DIM
88
- elif c == 22:
89
- bold = False
90
- elif c in COLORS:
91
- base = BRIGHT.get(c) if (bold and c in BRIGHT) else COLORS[c]
92
- color = base
93
- i += 1
94
- out.extend((color, bold, ch) for ch in text[pos:])
95
- return out
96
-
97
-
98
- def split_rows(cells: list[Cell]) -> list[list[Cell]]:
99
- rows: list[list[Cell]] = []
100
- row: list[Cell] = []
101
- for cell in cells:
102
- if cell[2] == "\n":
103
- rows.append(row)
104
- row = []
105
- else:
106
- row.append(cell)
107
- if row:
108
- rows.append(row)
109
- return rows
110
-
111
-
112
- def render(rows: list[list[Cell]], prompt: list[Cell], out_path: Path, title: str):
113
- font = ImageFont.truetype(r"C:\Windows\Fonts\consola.ttf", 26)
114
- font_bold = ImageFont.truetype(r"C:\Windows\Fonts\consolab.ttf", 26)
115
- cell_w = font.getlength("0")
116
- cell_h = 34
117
- pad = 34
118
- bar_h = 58
119
- gap = 12
120
-
121
- grid = [prompt] + [[]] + rows # prompt line, blank line, then output rows
122
- max_len = max(len(r) for r in grid)
123
- width = int(max_len * cell_w) + pad * 2
124
- height = bar_h + gap + cell_h + gap + len(grid[2:]) * cell_h + pad
125
-
126
- img = Image.new("RGB", (width, height), BG)
127
- d = ImageDraw.Draw(img)
128
-
129
- # title bar + traffic lights
130
- d.rectangle([0, 0, width, bar_h], fill=BAR_BG)
131
- for i, tc in enumerate(DOTS):
132
- cx = 22 + i * 38
133
- cy = bar_h // 2
134
- d.ellipse([cx - 11, cy - 11, cx + 11, cy + 11], fill=tc)
135
- tf = ImageFont.truetype(r"C:\Windows\Fonts\segoeui.ttf", 22)
136
- tw = d.textlength(title, font=tf)
137
- d.text(((width - tw) / 2, (bar_h - 24) / 2 + 2), title, font=tf, fill=(200, 205, 215))
138
-
139
- # prompt + output
140
- y = bar_h + gap
141
- for row in grid:
142
- x = 0.0
143
- for color, bold, ch in row:
144
- f = font_bold if bold else font
145
- # block glyphs fill their whole cell (PIL + Consolas leaves seams)
146
- if ch == "█":
147
- d.rectangle([pad + x, y, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
148
- elif ch == "▀":
149
- d.rectangle([pad + x, y, pad + x + cell_w - 1, y + cell_h / 2 - 1], fill=color)
150
- elif ch == "▄":
151
- d.rectangle([pad + x, y + cell_h / 2, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
152
- elif ch == "▌":
153
- d.rectangle([pad + x, y, pad + x + cell_w / 2 - 1, y + cell_h - 1], fill=color)
154
- elif ch == "▐":
155
- d.rectangle([pad + x + cell_w / 2, y, pad + x + cell_w - 1, y + cell_h - 1], fill=color)
156
- else:
157
- d.text((pad + x, y), ch, font=f, fill=color)
158
- x += cell_w
159
- y += cell_h
160
-
161
- # rounded corners
162
- mask = Image.new("L", (width, height), 0)
163
- ImageDraw.Draw(mask).rounded_rectangle([0, 0, width - 1, height - 1], radius=16, fill=255)
164
- rgba = img.convert("RGBA")
165
- rgba.putalpha(mask)
166
- rgba.save(out_path)
167
- print(f"saved {out_path} ({width}x{height})")
168
-
169
-
170
- def main():
171
- if len(sys.argv) < 4 or sys.argv.count("--") != 1:
172
- sys.exit(__doc__)
173
- out_path = Path(sys.argv[1])
174
- out_path.parent.mkdir(exist_ok=True)
175
- title = sys.argv[2]
176
-
177
- max_lines = None
178
- if "--max-lines" in sys.argv:
179
- i = sys.argv.index("--max-lines")
180
- max_lines = int(sys.argv[i + 1])
181
- del sys.argv[i : i + 2]
182
-
183
- # --from-file: read captured ANSI text instead of running the exe.
184
- # Used for the interactive app: drive_ui captures its real output
185
- # while the app is alive, then we render that capture.
186
- from_file = None
187
- if "--from-file" in sys.argv:
188
- i = sys.argv.index("--from-file")
189
- from_file = Path(sys.argv[i + 1])
190
- del sys.argv[i : i + 2]
191
-
192
- run_args = sys.argv[sys.argv.index("--") + 1 :]
193
-
194
- if from_file:
195
- body = from_file.read_text(encoding="utf-8") + "\x1b[0m"
196
- else:
197
- proc = subprocess.run(
198
- [str(EXE), *run_args],
199
- capture_output=True,
200
- text=True,
201
- encoding="utf-8",
202
- errors="replace",
203
- )
204
- body = (proc.stdout if proc.stdout.strip() else proc.stderr) + "\x1b[0m"
205
-
206
- prompt = [
207
- *[(DIM, False, ch) for ch in "PS C:\\sources\\costcrew> "],
208
- *tokenize_cli(title),
209
- (FG, False, "\n"),
210
- ]
211
- cells = prompt + tokenize_ansi(body)
212
- rows = split_rows(cells)
213
- if max_lines:
214
- rows = rows[: max_lines + 1] # prompt row + N body rows
215
- rows.append([]) # ellipsis hint line
216
- rows.append([(DIM, False, ch) for ch in f" ... full output: {title}"])
217
- render(rows[1:], rows[0], out_path, title)
218
-
219
-
220
- if __name__ == "__main__":
221
- main()
package/src/advise.rs DELETED
@@ -1,154 +0,0 @@
1
- //! `costcrew advise` — should you have used a cheaper model?
2
- //!
3
- //! Honest heuristic mode: recomputes your ACTUAL token mix under cheaper
4
- //! alternatives that publish benchmark tiers at/above the model you used.
5
- //! Quality scores are approximate tiers from public evals — the goal is to
6
- //! surface waste, not to write your model-selection policy.
7
-
8
- use crate::price::PriceDB;
9
- use crate::report::*;
10
- use crate::scan::Row;
11
-
12
- /// Approximate coding-benchmark tier per model family (5 = frontier).
13
- /// Rounded, contested, and deliberately conservative: advise only suggests
14
- /// swapping when the *candidate's tier is >= the model you used*.
15
- const TIERS: &[(&str, u8)] = &[
16
- ("claude-opus", 5),
17
- ("gpt-5", 5),
18
- ("grok-4", 5),
19
- ("gemini-3", 5),
20
- ("kimi-k2", 4),
21
- ("claude-sonnet", 4),
22
- ("glm-5", 4),
23
- ("deepseek-reasoner", 4),
24
- ("glm-4.6", 3),
25
- ("deepseek-chat", 3),
26
- ("qwen", 3),
27
- ("llama-4", 3),
28
- ("mistral-large", 3),
29
- ("gemini-2", 3),
30
- ("gpt-4.1", 3),
31
- ("gpt-4o", 3),
32
- ("o3", 4),
33
- ("claude-haiku", 3),
34
- ("mistral-small", 2),
35
- ("devstral", 2),
36
- ("codestral", 2),
37
- ("mistral", 2),
38
- ("minimax", 2),
39
- ("command", 2),
40
- ("phi", 2),
41
- ];
42
-
43
- /// Candidate pool for downgrades — cheapest-first, all tiers.
44
- const CANDIDATES: &[&str] = &[
45
- "claude-haiku-4",
46
- "glm-4.6",
47
- "deepseek-chat",
48
- "qwen3",
49
- "mistral-small",
50
- "devstral",
51
- "kimi-k2",
52
- "glm-5",
53
- "deepseek-reasoner",
54
- "claude-sonnet-4",
55
- "gemini-3",
56
- "gpt-5",
57
- "claude-opus-4",
58
- ];
59
-
60
- fn tier_of(model: &str) -> u8 {
61
- let m = model.to_lowercase();
62
- for (k, t) in TIERS {
63
- if m.starts_with(k) {
64
- return *t;
65
- }
66
- }
67
- 0
68
- }
69
-
70
- /// What this row's token mix would cost on an alternative model.
71
- fn est_cost(row: &Row, alt: &str, db: &PriceDB) -> Option<f64> {
72
- let p = db.lookup(alt)?;
73
- Some(
74
- (row.input_tokens as f64 / 1e6) * p.input
75
- + (row.output_tokens as f64 / 1e6) * p.output
76
- + (row.cache_read_tokens as f64 / 1e6) * p.cache_read
77
- + (row.cache_write_tokens as f64 / 1e6) * p.cache_write
78
- + p.per_hour * (row.duration_secs / 3600.0),
79
- )
80
- }
81
-
82
- pub fn print_advise(rows: &[Row], db: &PriceDB, since_days: u32) {
83
- println!();
84
- println!(
85
- " {BOLD}costcrew advise{RESET} {DIM}· cheaper-model verdicts · last {since_days}d{RESET}"
86
- );
87
- println!();
88
-
89
- if !rows.iter().any(|r| r.known_price && r.requests > 0) {
90
- println!(" {DIM}nothing priced to advise on — run `costcrew` first.{RESET}");
91
- return;
92
- }
93
-
94
- let mut suggested_ok = 0usize;
95
- for r in rows.iter().filter(|r| r.known_price && r.cost > 0.01) {
96
- let my_tier = tier_of(&r.model);
97
- if my_tier == 0 {
98
- // don't pretend to know about unknown-model quality
99
- continue;
100
- }
101
- let mut alts: Vec<(f64, &str, u8)> = CANDIDATES
102
- .iter()
103
- .filter(|c| tier_of(c) >= my_tier)
104
- .filter_map(|c| est_cost(r, c, db).map(|cost| (cost, *c, tier_of(c))))
105
- .filter(|(cost, _, _)| *cost < r.cost * 0.9)
106
- .collect();
107
- alts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
108
-
109
- if let Some((alt_cost, alt_name, alt_tier)) = alts.first().copied() {
110
- let save = r.cost - alt_cost;
111
- let pct = (1.0 - alt_cost / r.cost).max(0.0) * 100.0;
112
- let tier_note = if alt_tier == my_tier {
113
- "same tier"
114
- } else {
115
- "higher tier"
116
- };
117
- println!(
118
- " {YELLOW}{:>30}{RESET} -> {GREEN}{:<22}{RESET} {DIM}({} · probable save ${:.2} ({:.0}%)){RESET}",
119
- truncate(&r.model, 30),
120
- alt_name,
121
- tier_note,
122
- save,
123
- pct
124
- );
125
- println!(
126
- " {:>30} {DIM} same work would have cost ${:.2} instead of ${:.2}{RESET}",
127
- "", alt_cost, r.cost
128
- );
129
- suggested_ok += 1;
130
- }
131
- }
132
-
133
- if suggested_ok == 0 {
134
- println!(" {GREEN}✓ no waste pattern detected in the last {since_days}d — your tiers match your spend.{RESET}");
135
- } else {
136
- println!();
137
- println!(
138
- " {DIM}heuristic: tiers from public coding evals, costs = your exact token mix{RESET}"
139
- );
140
- println!(
141
- " {DIM}at those prices. quality isn't guaranteed — run one task as an A/B and{RESET}"
142
- );
143
- println!(" {DIM}compare before rewiring your whole workflow.{RESET}");
144
- }
145
- println!();
146
- }
147
-
148
- fn truncate(s: &str, max: usize) -> String {
149
- if s.len() <= max {
150
- s.to_string()
151
- } else {
152
- format!("{}…", &s[..max - 1])
153
- }
154
- }
package/src/args.rs DELETED
@@ -1,239 +0,0 @@
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
- }