@archeagle087/costcrew 0.4.1 → 0.4.3
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.
- package/README.md +1 -0
- package/bin.js +10 -6
- package/package.json +8 -5
- package/.github/workflows/ci.yml +0 -21
- package/.github/workflows/npm-release.yml +0 -123
- package/.github/workflows/prices-monthly.yml +0 -58
- package/.github/workflows/release.yml +0 -45
- package/Cargo.lock +0 -980
- package/Cargo.toml +0 -22
- package/assets/advise.png +0 -0
- package/assets/board.png +0 -0
- package/assets/catalog.png +0 -0
- package/assets/demo.png +0 -0
- package/assets/help.png +0 -0
- package/assets/mascot.png +0 -0
- package/assets/prices.png +0 -0
- package/assets/scan.png +0 -0
- package/assets/spend.png +0 -0
- package/assets/ui.png +0 -0
- package/data/prices.json +0 -1
- package/dist/costcrew-linux-x86_64.tar.gz +0 -0
- package/dist/costcrew-macos-aarch64.tar.gz +0 -0
- package/dist/costcrew-macos-x86_64.tar.gz +0 -0
- package/dist/costcrew-windows-x86_64.zip +0 -0
- package/install.ps1 +0 -38
- package/install.sh +0 -56
- package/packaging/npm/bin.js +0 -46
- package/packaging/npm/package.json +0 -20
- package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +0 -15
- package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +0 -28
- package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +0 -5
- package/packaging/winget/README.md +0 -31
- package/scripts/render_screen.py +0 -221
- package/src/advise.rs +0 -154
- package/src/args.rs +0 -239
- package/src/board.rs +0 -227
- package/src/catalog.rs +0 -136
- package/src/demo.rs +0 -79
- package/src/fetch.rs +0 -155
- package/src/main.rs +0 -371
- package/src/price.rs +0 -218
- package/src/prices.rs +0 -182
- package/src/report.rs +0 -194
- package/src/scan.rs +0 -593
- package/src/spend.rs +0 -452
- package/src/ui.rs +0 -367
- package/work/costcrew-darwin-arm64/costcrew +0 -0
- package/work/costcrew-darwin-arm64/package.json +0 -9
- package/work/costcrew-darwin-x64/costcrew +0 -0
- package/work/costcrew-darwin-x64/package.json +0 -9
- package/work/costcrew-linux-x64/costcrew +0 -0
- package/work/costcrew-linux-x64/package.json +0 -9
- package/work/costcrew-win32-x64/costcrew.exe +0 -0
- package/work/costcrew-win32-x64/package.json +0 -9
package/scripts/render_screen.py
DELETED
|
@@ -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
|
-
}
|