@archeagle087/costcrew 0.0.0-stage → 0.4.0

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 +107 -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 +43 -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 +43 -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/ui.rs ADDED
@@ -0,0 +1,367 @@
1
+ //! `costcrew` interactive mode — the fancy bit.
2
+ //!
3
+ //! Bare `costcrew` opens this instead of dumping a table: the banner builds
4
+ //! itself in, a spinner runs while the bundled database loads, menu items
5
+ //! cascade in — then it's arrow keys. When you pick an action the logo
6
+ //! clears away and the real output takes the screen; any key returns you to
7
+ //! the menu.
8
+ //!
9
+ //! Rendering is plain ANSI (same style constants as the rest of the app);
10
+ //! crossterm is used only to capture key presses. Anti-flash notes:
11
+ //! - raw mode is enabled ONCE for the whole menu loop (toggling it per
12
+ //! keypress is what caused the flicker), and only dropped while a view
13
+ //! runs with normal `println!` output;
14
+ //! - every menu frame is composed into one String and written in a single
15
+ //! `write_all` — partial repaints are what read as "flashing";
16
+ //! - frames use `\r\n` line endings, required while raw mode is on.
17
+
18
+ use crate::args::{Args, Cmd};
19
+ use crate::price::PriceDB;
20
+ use crate::report::{BOLD, CYAN, DIM, RESET, YELLOW};
21
+ use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
22
+ use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
23
+ use std::io::Write;
24
+
25
+ /// ANSI 256-color cyan→blue gradient rows for the banner
26
+ const GRAD: [&str; 5] = [
27
+ "\x1b[38;5;51m",
28
+ "\x1b[38;5;45m",
29
+ "\x1b[38;5;39m",
30
+ "\x1b[38;5;33m",
31
+ "\x1b[38;5;27m",
32
+ ];
33
+
34
+ /// "COST / CREW" in solid blocks (double-line box glyphs render as tofu in
35
+ /// plain conhost; solid █ renders everywhere)
36
+ #[rustfmt::skip]
37
+ const BANNER: &[&str] = &[
38
+ "█████ █████ █████ █████",
39
+ "█ █ █ █ █ ",
40
+ "█ █ █ █████ █ ",
41
+ "█ █ █ █ █ ",
42
+ "█████ █████ █████ █ ",
43
+ "█████ █████ █████ █ █",
44
+ "█ █ █ █ █ █",
45
+ "█ █████ ████ █ █ █",
46
+ "█ █ █ █ █ █ █",
47
+ "█████ █ █ █████ ██ ██",
48
+ ];
49
+
50
+ /// the costcrew mascot: a coin with a dollar-sign, mid-spin.
51
+ /// Only block glyphs + ASCII, so it renders in every terminal font.
52
+ #[rustfmt::skip]
53
+ const MASCOT_FACE: &[&str] = &[
54
+ " ▄▄▄▄▄ ",
55
+ "█ $ █",
56
+ " ▀▀▀▀▀ ",
57
+ ];
58
+
59
+ #[rustfmt::skip]
60
+ const MASCOT_THREEQUARTER: &[&str] = &[
61
+ " ▄▄▄▄ ",
62
+ "▌ $ ▐",
63
+ " ▀▀▀▀ ",
64
+ ];
65
+
66
+ #[rustfmt::skip]
67
+ const MASCOT_EDGE: &[&str] = &[
68
+ " ▄▄ ",
69
+ "▌$▐",
70
+ " ▀▀ ",
71
+ ];
72
+
73
+ struct Item {
74
+ name: &'static str,
75
+ hint: &'static str,
76
+ }
77
+
78
+ const ITEMS: [Item; 10] = [
79
+ Item {
80
+ name: "price database",
81
+ hint: "every model, bundled, offline — browse & filter",
82
+ },
83
+ Item {
84
+ name: "your spend",
85
+ hint: "what YOU pay for AI: monthly totals, per service",
86
+ },
87
+ Item {
88
+ name: "add a payment",
89
+ hint: "record what you just paid (sub, credits, GPU)",
90
+ },
91
+ Item {
92
+ name: "leaderboard",
93
+ hint: "arena Elo ranked with prices — elo-per-dollar",
94
+ },
95
+ Item {
96
+ name: "agent catalog",
97
+ hint: "which agent ships with which models",
98
+ },
99
+ Item {
100
+ name: "openrouter actuals",
101
+ hint: "provider-reported spend from your API key",
102
+ },
103
+ Item {
104
+ name: "refresh prices",
105
+ hint: "update the database from OpenRouter live",
106
+ },
107
+ Item {
108
+ name: "scan local logs",
109
+ hint: "optional: estimate per-token from agent logs",
110
+ },
111
+ Item {
112
+ name: "cheaper-model advice",
113
+ hint: "could you have used a cheaper model?",
114
+ },
115
+ Item {
116
+ name: "demo",
117
+ hint: "sample report, no data needed",
118
+ },
119
+ ];
120
+
121
+ const FOOTER: &str = "↑/↓ move · Enter select · 1-9,0 jump · q quit";
122
+
123
+ const CLEAR: &str = "\x1b[2J\x1b[H\x1b[?25l";
124
+
125
+ pub fn run_app() {
126
+ let mut out = std::io::stdout();
127
+ let _ = out.write_all(CLEAR.as_bytes());
128
+
129
+ // --- the opener: the coin mascot spins while the database loads ---
130
+ let mut db = PriceDB::builtin();
131
+ crate::fetch::cached_into_db(&mut db);
132
+ let n = db.entries().len();
133
+ let spin = [
134
+ MASCOT_FACE,
135
+ MASCOT_THREEQUARTER,
136
+ MASCOT_EDGE,
137
+ MASCOT_THREEQUARTER,
138
+ ];
139
+ for (i, frame) in spin.iter().cycle().take(18).enumerate() {
140
+ if i > 0 {
141
+ // rewind above the 3 mascot lines to redraw in place
142
+ let _ = out.write_all(b"\x1b[3A");
143
+ }
144
+ let mut block = String::new();
145
+ for line in *frame {
146
+ block.push_str(&format!("\x1b[2K\r{CYAN}{line}{RESET}\r\n"));
147
+ }
148
+ block.push_str(&format!(
149
+ "\x1b[2K\r {DIM}loading bundled price database · {n} models{RESET}"
150
+ ));
151
+ let _ = out.write_all(block.as_bytes());
152
+ out.flush().ok();
153
+ std::thread::sleep(std::time::Duration::from_millis(115));
154
+ }
155
+ std::thread::sleep(std::time::Duration::from_millis(250));
156
+
157
+ // --- menu items cascade in, banner on top ---
158
+ let mut sel = 0usize;
159
+ draw_menu(&mut out, &db, sel, true);
160
+
161
+ // --- input loop: raw mode stays ON so keypresses don't flicker ---
162
+ if enable_raw_mode().is_err() {
163
+ return;
164
+ }
165
+ while let Some(key) = read_key() {
166
+ match key.code {
167
+ KeyCode::Esc => break,
168
+ KeyCode::Char('q') | KeyCode::Char('Q') => break,
169
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
170
+ KeyCode::Up => {
171
+ sel = sel.wrapping_sub(1).min(ITEMS.len() - 1);
172
+ draw_menu(&mut out, &db, sel, false);
173
+ }
174
+ KeyCode::Down | KeyCode::Tab => {
175
+ sel = (sel + 1) % ITEMS.len();
176
+ draw_menu(&mut out, &db, sel, false);
177
+ }
178
+ KeyCode::Enter => {
179
+ let _ = disable_raw_mode(); // views print with plain println!
180
+ act(sel, &mut out);
181
+ if enable_raw_mode().is_err() {
182
+ break;
183
+ }
184
+ draw_menu(&mut out, &db, sel, false);
185
+ }
186
+ KeyCode::Char(c) => {
187
+ let idx = match c {
188
+ '1'..='9' => (c as u8 - b'1') as usize,
189
+ '0' => 9,
190
+ _ => continue,
191
+ };
192
+ if idx < ITEMS.len() {
193
+ sel = idx;
194
+ let _ = disable_raw_mode();
195
+ act(sel, &mut out);
196
+ if enable_raw_mode().is_err() {
197
+ break;
198
+ }
199
+ draw_menu(&mut out, &db, sel, false);
200
+ }
201
+ }
202
+ _ => {}
203
+ }
204
+ }
205
+
206
+ // restore terminal
207
+ let _ = disable_raw_mode();
208
+ let _ = out.write_all(b"\x1b[?25h\x1b[0m\r\n");
209
+ out.flush().ok();
210
+ println!(
211
+ " {DIM}price database travels with you — {RESET}{CYAN}costcrew spend add 19.99{RESET} {DIM}from any shell{RESET}"
212
+ );
213
+ }
214
+
215
+ /// run one menu item: clear the logo away, do the thing, wait for a key
216
+ fn act(sel: usize, out: &mut std::io::Stdout) {
217
+ let _ = out.write_all("\x1b[2J\x1b[H\x1b[?25h".as_bytes());
218
+ out.flush().ok();
219
+
220
+ match sel {
221
+ 0 => {
222
+ let q = prompt("filter the database (enter = everything)");
223
+ let mut args = base(Cmd::Prices);
224
+ if !q.is_empty() {
225
+ args.pos.push(q);
226
+ }
227
+ crate::flow(args);
228
+ }
229
+ 1 => crate::flow(base(Cmd::Spend)),
230
+ 2 => crate::spend::interactive_add(),
231
+ 3 => {
232
+ let mut args = base(Cmd::Leaderboard);
233
+ args.top = 25;
234
+ crate::flow(args);
235
+ }
236
+ 4 => crate::flow(base(Cmd::Catalog)),
237
+ 5 => {
238
+ let key = match std::env::var("OPENROUTER_API_KEY") {
239
+ Ok(k) => k,
240
+ Err(_) => prompt("OpenRouter key (sk-or-v1-...)"),
241
+ };
242
+ let mut args = base(Cmd::OpenRouter);
243
+ if !key.is_empty() {
244
+ args.key = Some(key);
245
+ }
246
+ crate::flow(args);
247
+ }
248
+ 6 => crate::flow(base(Cmd::Fetch)),
249
+ 7 => crate::flow(base(Cmd::Scan)),
250
+ 8 => crate::flow(base(Cmd::Advise)),
251
+ 9 => crate::flow(base(Cmd::Demo)),
252
+ _ => {}
253
+ }
254
+
255
+ // "press any key" pause, then back to the menu
256
+ println!();
257
+ println!(" {DIM}press any key to go back{RESET}");
258
+ std::io::stdout().flush().ok();
259
+ let _ = wait_any_key();
260
+ }
261
+
262
+ // ---------- frames ----------
263
+
264
+ /// full menu frame — banner on top of the options, always
265
+ fn frame(db: &PriceDB, sel: usize) -> String {
266
+ let mut s = String::from(CLEAR);
267
+ for (i, line) in BANNER.iter().enumerate() {
268
+ s.push_str(GRAD[i % GRAD.len()]);
269
+ s.push_str(line);
270
+ s.push_str(RESET);
271
+ s.push_str("\r\n");
272
+ }
273
+ s.push_str(&format!(
274
+ "\r\n {BOLD}the AI coding price database{RESET} {DIM}· {n} models bundled · offline · v{}{RESET}\r\n\r\n",
275
+ env!("CARGO_PKG_VERSION"),
276
+ n = db.entries().len()
277
+ ));
278
+ for (i, it) in ITEMS.iter().enumerate() {
279
+ let num = if i == 9 {
280
+ "0".to_string()
281
+ } else {
282
+ format!("{}", i + 1)
283
+ };
284
+ if i == sel {
285
+ s.push_str(&format!(
286
+ " {CYAN}▸ {num} {BOLD}{:<20}{RESET} {CYAN}{}{RESET}\r\n",
287
+ it.name, it.hint
288
+ ));
289
+ } else {
290
+ s.push_str(&format!(
291
+ " {DIM}{num} {:<20}{RESET} {DIM}{}{RESET}\r\n",
292
+ it.name, it.hint
293
+ ));
294
+ }
295
+ }
296
+ s.push_str(&format!("\r\n {YELLOW}{FOOTER}{RESET}\r\n"));
297
+ s
298
+ }
299
+
300
+ fn draw_menu(out: &mut std::io::Stdout, db: &PriceDB, sel: usize, animate: bool) {
301
+ let f = frame(db, sel);
302
+ if animate {
303
+ for line in f.split("\r\n") {
304
+ write_line(out, line);
305
+ std::thread::sleep(std::time::Duration::from_millis(16));
306
+ }
307
+ } else {
308
+ // single atomic write — no partial repaint, no flash
309
+ let _ = out.write_all(f.as_bytes());
310
+ out.flush().ok();
311
+ }
312
+ }
313
+
314
+ fn write_line(out: &mut std::io::Stdout, s: &str) {
315
+ let _ = out.write_all(s.as_bytes());
316
+ let _ = out.write_all(b"\r\n");
317
+ out.flush().ok();
318
+ }
319
+
320
+ // ---------- terminal helpers ----------
321
+
322
+ fn base(cmd: Cmd) -> Args {
323
+ Args {
324
+ cmd,
325
+ json: false,
326
+ since_days: 30,
327
+ help: false,
328
+ key: None,
329
+ prices: None,
330
+ sort: None,
331
+ top: 25,
332
+ pos: Vec::new(),
333
+ service: None,
334
+ model: None,
335
+ note: None,
336
+ at: None,
337
+ }
338
+ }
339
+
340
+ /// one key press (skips release events); requires raw mode to be ON
341
+ fn read_key() -> Option<KeyEvent> {
342
+ loop {
343
+ match read() {
344
+ Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => return Some(k),
345
+ Ok(_) => continue, // release events, resize, focus, etc.
346
+ Err(_) => return None,
347
+ }
348
+ }
349
+ }
350
+
351
+ /// for the "press any key" pause — raw mode is OFF here, toggles it itself
352
+ fn wait_any_key() -> Option<KeyEvent> {
353
+ if enable_raw_mode().is_err() {
354
+ return None;
355
+ }
356
+ let k = read_key();
357
+ let _ = disable_raw_mode();
358
+ k
359
+ }
360
+
361
+ fn prompt(label: &str) -> String {
362
+ print!(" {CYAN}{label}{RESET} ");
363
+ std::io::stdout().flush().ok();
364
+ let mut s = String::new();
365
+ std::io::stdin().read_line(&mut s).ok();
366
+ s.trim().to_string()
367
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@archeagle087/costcrew-darwin-arm64",
3
+ "version": "0.4.0",
4
+ "description": "costcrew native binary (costcrew-darwin-arm64) — do not install directly",
5
+ "os": ["darwin"],
6
+ "cpu": ["arm64"],
7
+ "license": "MIT",
8
+ "repository": "github:Arch087146/costcrew"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@archeagle087/costcrew-darwin-x64",
3
+ "version": "0.4.0",
4
+ "description": "costcrew native binary (costcrew-darwin-x64) — do not install directly",
5
+ "os": ["darwin"],
6
+ "cpu": ["x64"],
7
+ "license": "MIT",
8
+ "repository": "github:Arch087146/costcrew"
9
+ }
Binary file
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@archeagle087/costcrew-linux-x64",
3
+ "version": "0.4.0",
4
+ "description": "costcrew native binary (costcrew-linux-x64) — do not install directly",
5
+ "os": ["linux"],
6
+ "cpu": ["x64"],
7
+ "license": "MIT",
8
+ "repository": "github:Arch087146/costcrew"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@archeagle087/costcrew-win32-x64",
3
+ "version": "0.4.0",
4
+ "description": "costcrew native binary (costcrew-win32-x64) — do not install directly",
5
+ "os": ["win32"],
6
+ "cpu": ["x64"],
7
+ "license": "MIT",
8
+ "repository": "github:Arch087146/costcrew"
9
+ }