@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.
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/npm-release.yml +107 -0
- package/.github/workflows/prices-monthly.yml +58 -0
- package/.github/workflows/release.yml +45 -0
- package/Cargo.lock +980 -0
- package/Cargo.toml +22 -0
- package/LICENSE +21 -0
- package/README.md +255 -2
- 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/bin.js +43 -0
- package/data/prices.json +1 -0
- 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 +38 -0
- package/install.sh +56 -0
- package/package.json +18 -4
- package/packaging/npm/bin.js +43 -0
- package/packaging/npm/package.json +20 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +15 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +28 -0
- package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +5 -0
- package/packaging/winget/README.md +31 -0
- package/scripts/render_screen.py +221 -0
- package/src/advise.rs +154 -0
- package/src/args.rs +239 -0
- package/src/board.rs +227 -0
- package/src/catalog.rs +136 -0
- package/src/demo.rs +79 -0
- package/src/fetch.rs +155 -0
- package/src/main.rs +371 -0
- package/src/price.rs +218 -0
- package/src/prices.rs +182 -0
- package/src/report.rs +194 -0
- package/src/scan.rs +593 -0
- package/src/spend.rs +452 -0
- package/src/ui.rs +367 -0
- package/work/costcrew-darwin-arm64/costcrew +0 -0
- package/work/costcrew-darwin-arm64/package.json +9 -0
- package/work/costcrew-darwin-x64/costcrew +0 -0
- package/work/costcrew-darwin-x64/package.json +9 -0
- package/work/costcrew-linux-x64/costcrew +0 -0
- package/work/costcrew-linux-x64/package.json +9 -0
- package/work/costcrew-win32-x64/costcrew.exe +0 -0
- package/work/costcrew-win32-x64/package.json +9 -0
package/install.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# costcrew installer — curl -fsSL https://raw.githubusercontent.com/Arch087146/costcrew/main/install.sh | sh
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
REPO="Arch087146/costcrew"
|
|
6
|
+
VERSION="${COSTCREW_VERSION:-latest}"
|
|
7
|
+
|
|
8
|
+
# pick the asset for this machine
|
|
9
|
+
OS="$(uname -s)"
|
|
10
|
+
ARCH="$(uname -m)"
|
|
11
|
+
case "$OS" in
|
|
12
|
+
Darwin) OS_TAG="macos" ;;
|
|
13
|
+
Linux) OS_TAG="linux" ;;
|
|
14
|
+
*) echo "unsupported OS: $OS (windows users: use install.ps1)" >&2; exit 1 ;;
|
|
15
|
+
esac
|
|
16
|
+
case "$ARCH" in
|
|
17
|
+
arm64 | aarch64) ARCH_TAG="aarch64" ;;
|
|
18
|
+
x86_64 | amd64) ARCH_TAG="x86_64" ;;
|
|
19
|
+
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;;
|
|
20
|
+
esac
|
|
21
|
+
ASSET="costcrew-${OS_TAG}-${ARCH_TAG}.tar.gz"
|
|
22
|
+
|
|
23
|
+
if [ "$VERSION" = "latest" ]; then
|
|
24
|
+
URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
|
|
25
|
+
else
|
|
26
|
+
URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# install destination: writable dir on PATH, else ~/.local/bin
|
|
30
|
+
if [ -n "${COSTCREW_INSTALL_DIR:-}" ]; then
|
|
31
|
+
DEST="$COSTCREW_INSTALL_DIR"
|
|
32
|
+
elif [ -w /usr/local/bin ] 2>/dev/null; then
|
|
33
|
+
DEST="/usr/local/bin"
|
|
34
|
+
else
|
|
35
|
+
DEST="${HOME}/.local/bin"
|
|
36
|
+
mkdir -p "$DEST"
|
|
37
|
+
case ":$PATH:" in
|
|
38
|
+
*":$DEST:"*) ;;
|
|
39
|
+
*) echo "note: add $DEST to your PATH: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
|
|
40
|
+
esac
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
TMP="$(mktemp -d)"
|
|
44
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
45
|
+
|
|
46
|
+
echo "fetching ${ASSET} ..."
|
|
47
|
+
curl -fsSL "$URL" -o "$TMP/costcrew.tar.gz"
|
|
48
|
+
tar xzf "$TMP/costcrew.tar.gz" -C "$TMP"
|
|
49
|
+
|
|
50
|
+
mv "$TMP/costcrew" "$DEST/costcrew"
|
|
51
|
+
chmod +x "$DEST/costcrew"
|
|
52
|
+
|
|
53
|
+
echo
|
|
54
|
+
echo "installed: $(command -v costcrew 2>/dev/null || echo "$DEST/costcrew")"
|
|
55
|
+
"$DEST/costcrew" --version
|
|
56
|
+
echo "run costcrew to open the app · costcrew prices · costcrew spend"
|
package/package.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@archeagle087/costcrew",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "The AI coding price database, plus what you spend on it — interactive terminal app. Bundled 465+ model prices, your spend ledger, arena leaderboard priced elo-per-dollar.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"costcrew": "bin.js"
|
|
7
|
+
},
|
|
8
|
+
"optionalDependencies": {
|
|
9
|
+
"@archeagle087/costcrew-win32-x64": "0.4.0",
|
|
10
|
+
"@archeagle087/costcrew-linux-x64": "0.4.0",
|
|
11
|
+
"@archeagle087/costcrew-darwin-arm64": "0.4.0",
|
|
12
|
+
"@archeagle087/costcrew-darwin-x64": "0.4.0"
|
|
13
|
+
},
|
|
14
|
+
"os": ["win32", "linux", "darwin"],
|
|
15
|
+
"cpu": ["x64", "arm64"],
|
|
16
|
+
"keywords": ["ai", "llm", "cost", "tokens", "pricing", "claude", "opencode", "cli"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": "github:Arch087146/costcrew",
|
|
19
|
+
"homepage": "https://github.com/Arch087146/costcrew#readme"
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// costcrew npm wrapper: finds the platform binary installed as an
|
|
3
|
+
// optionalDependency and execs it with all arguments passed through.
|
|
4
|
+
"use strict";
|
|
5
|
+
const { spawnSync } = require("child_process");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
|
|
9
|
+
const PLATFORMS = {
|
|
10
|
+
"win32-x64": "@archeagle087/costcrew-win32-x64/costcrew.exe",
|
|
11
|
+
"linux-x64": "@archeagle087/costcrew-linux-x64/costcrew",
|
|
12
|
+
"darwin-arm64": "@archeagle087/costcrew-darwin-arm64/costcrew",
|
|
13
|
+
"darwin-x64": "@archeagle087/costcrew-darwin-x64/costcrew",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const key = `${process.platform}-${process.arch}`;
|
|
17
|
+
const rel = PLATFORMS[key];
|
|
18
|
+
if (!rel) {
|
|
19
|
+
console.error(`costcrew: no prebuilt binary for ${key}`);
|
|
20
|
+
console.error("build from source: https://github.com/Arch087146/costcrew");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveBinary() {
|
|
25
|
+
const direct = path.join(__dirname, "..", rel);
|
|
26
|
+
if (fs.existsSync(direct)) return direct;
|
|
27
|
+
// npm can hoist/reshape optionalDependencies — fall back to resolution
|
|
28
|
+
const [pkg, file] = rel.split("/");
|
|
29
|
+
try {
|
|
30
|
+
const dir = path.dirname(require.resolve(`${pkg}/package.json`, { paths: [__dirname] }));
|
|
31
|
+
return path.join(dir, file);
|
|
32
|
+
} catch {
|
|
33
|
+
return direct; // let spawn fail with a clear ENOENT
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const bin = resolveBinary();
|
|
38
|
+
const result = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" });
|
|
39
|
+
if (result.error) {
|
|
40
|
+
console.error(`costcrew: failed to launch ${bin}: ${result.error.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
process.exit(result.status ?? 0);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@archeagle087/costcrew",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "The AI coding price database, plus what you spend on it — interactive terminal app. Bundled 465+ model prices, your spend ledger, arena leaderboard priced elo-per-dollar.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"costcrew": "bin.js"
|
|
7
|
+
},
|
|
8
|
+
"optionalDependencies": {
|
|
9
|
+
"@archeagle087/costcrew-win32-x64": "0.4.0",
|
|
10
|
+
"@archeagle087/costcrew-linux-x64": "0.4.0",
|
|
11
|
+
"@archeagle087/costcrew-darwin-arm64": "0.4.0",
|
|
12
|
+
"@archeagle087/costcrew-darwin-x64": "0.4.0"
|
|
13
|
+
},
|
|
14
|
+
"os": ["win32", "linux", "darwin"],
|
|
15
|
+
"cpu": ["x64", "arm64"],
|
|
16
|
+
"keywords": ["ai", "llm", "cost", "tokens", "pricing", "claude", "opencode", "cli"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": "github:Arch087146/costcrew",
|
|
19
|
+
"homepage": "https://github.com/Arch087146/costcrew#readme"
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
PackageIdentifier: Arch087146.costcrew
|
|
2
|
+
PackageVersion: 0.4.0
|
|
3
|
+
Installers:
|
|
4
|
+
- Architecture: x64
|
|
5
|
+
InstallerType: zip
|
|
6
|
+
InstallerUrl: https://github.com/Arch087146/costcrew/releases/download/v0.4.0/costcrew-windows-x86_64.zip
|
|
7
|
+
InstallerSha256: 9B4F651A49D49AA338E68C008F33E4CC6D00E50D576026D05E81CB33385367CE
|
|
8
|
+
NestedInstallerType: portable
|
|
9
|
+
NestedInstallerFiles:
|
|
10
|
+
- RelativeFilePath: costcrew.exe
|
|
11
|
+
PortableCommandAlias: costcrew
|
|
12
|
+
InstallerSwitches:
|
|
13
|
+
Portable: ''
|
|
14
|
+
ManifestType: installer
|
|
15
|
+
ManifestVersion: 1.9.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
PackageIdentifier: Arch087146.costcrew
|
|
2
|
+
PackageVersion: 0.4.0
|
|
3
|
+
PackageLocale: en-US
|
|
4
|
+
Publisher: Arch087146
|
|
5
|
+
PublisherUrl: https://github.com/Arch087146
|
|
6
|
+
PackageName: costcrew
|
|
7
|
+
PackageUrl: https://github.com/Arch087146/costcrew
|
|
8
|
+
License: MIT
|
|
9
|
+
LicenseUrl: https://github.com/Arch087146/costcrew/blob/main/LICENSE
|
|
10
|
+
ShortDescription: The AI coding price database, plus what you spend on it.
|
|
11
|
+
Description: |-
|
|
12
|
+
One binary, two halves. First: a bundled database of what every AI model
|
|
13
|
+
costs - every input/output/cache rate per 1M tokens, works offline, no API
|
|
14
|
+
key, nothing to configure. Second: your own spend, recorded by you, totaled
|
|
15
|
+
by month and service, plus provider-reported truth from your API keys.
|
|
16
|
+
Type `costcrew` for the interactive app; flags like `costcrew prices claude`
|
|
17
|
+
work for scripting.
|
|
18
|
+
Moniker: costcrew
|
|
19
|
+
Tags:
|
|
20
|
+
- ai
|
|
21
|
+
- llm
|
|
22
|
+
- cost
|
|
23
|
+
- pricing
|
|
24
|
+
- tokens
|
|
25
|
+
- cli
|
|
26
|
+
- rust
|
|
27
|
+
ManifestType: defaultLocale
|
|
28
|
+
ManifestVersion: 1.9.0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# winget manifests
|
|
2
|
+
|
|
3
|
+
These three files submit costcrew to the official winget community repo.
|
|
4
|
+
|
|
5
|
+
## How to submit (one-time per release)
|
|
6
|
+
|
|
7
|
+
1. Fork https://github.com/microsoft/winget-pkgs
|
|
8
|
+
2. Copy the `0.4.0/` folder (all three YAML files) to
|
|
9
|
+
`manifests/a/Arch087146/costcrew/0.4.0/` in your fork.
|
|
10
|
+
3. Open a PR against `microsoft/winget-pkgs` with title
|
|
11
|
+
`Add: Arch087146.costcrew version 0.4.0`.
|
|
12
|
+
4. The PR checks will run winget's manifest validator automatically.
|
|
13
|
+
Once a maintainer approves, the package goes live and
|
|
14
|
+
`winget install Arch087146.costcrew` works.
|
|
15
|
+
|
|
16
|
+
## Updating hashes
|
|
17
|
+
|
|
18
|
+
`InstallerSha256` in `Arch087146.costcrew.installer.yaml` is the SHA256 of
|
|
19
|
+
`costcrew-windows-x86_64.zip` from release v0.4.0. When a new version is
|
|
20
|
+
released, compute it with:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
Get-FileHash .\costcrew-windows-x86_64.zip -Algorithm SHA256
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Validating locally (optional)
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
winget install Microsoft.Winget.SourceValidation
|
|
30
|
+
winget validate --manifest .\0.4.0\
|
|
31
|
+
```
|
|
@@ -0,0 +1,221 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
}
|