@caius_kong/ccusage-dashboard 0.1.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/README.md +41 -0
- package/bin/ccusage-ui.js +54 -0
- package/lib/index.html +281 -0
- package/lib/server.py +311 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ccusage-ui
|
|
2
|
+
|
|
3
|
+
A tiny, zero-dependency local dashboard for [ccusage](https://github.com/ccusage/ccusage).
|
|
4
|
+
Shows **today / this week / this month / custom range** cost & tokens grouped by model,
|
|
5
|
+
a **30-day cost trend chart**, and a **monthly budget alert** (default cap $300).
|
|
6
|
+
Auto-refreshing, numbers straight from ccusage.
|
|
7
|
+
|
|
8
|
+
All numbers come directly from `ccusage ... --json`, so the figures always match
|
|
9
|
+
what ccusage reports (no own pricing tables, no drift).
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python3 server.py # → http://127.0.0.1:8799
|
|
15
|
+
python3 server.py --port 9000 # different port
|
|
16
|
+
python3 server.py --budget 500 # change monthly budget cap (default 300)
|
|
17
|
+
python3 server.py --ccusage-path /path/to/ccusage # use a specific binary
|
|
18
|
+
./run.sh # opens the browser automatically
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.8+ (stdlib only). The server resolves a fast local ccusage
|
|
22
|
+
(npm/bun cache → PATH → `npx`), warms caches synchronously (~3s), then serves,
|
|
23
|
+
so the first page load is instant rather than a 7s cold wait. Use `--no-warm`
|
|
24
|
+
to skip that and accept a slower first load.
|
|
25
|
+
|
|
26
|
+
## Endpoints
|
|
27
|
+
|
|
28
|
+
| Path | What it returns |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `/` | the dashboard |
|
|
31
|
+
| `/api/today` | today's totals + per-model breakdown (cached 15s) |
|
|
32
|
+
| `/api/week` | this week's totals + per-model breakdown (cached 60s) |
|
|
33
|
+
| `/api/month` | this month's totals per-model, plus `budget` + `budgetUsedPct` (cached 60s) |
|
|
34
|
+
| `/api/range?from=YYYY-MM-DD&to=YYYY-MM-DD` | aggregated totals for a date range (cached 120s) |
|
|
35
|
+
| `/api/trend?days=30` | per-day cost series for the last N days (2m cache, capped 366) |
|
|
36
|
+
| `/api/health` | liveness check incl. current budget |
|
|
37
|
+
|
|
38
|
+
## Files
|
|
39
|
+
|
|
40
|
+
- `server.py` — Python stdlib HTTP server; resolves a local ccusage, shells out with `--json --offline`, warms caches on boot
|
|
41
|
+
- `index.html` — single-file dashboard (no build step, no CDN)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ccusage-dashboard — one-command launcher.
|
|
4
|
+
*
|
|
5
|
+
* Locates the bundled server.py (and its index.html sibling) and runs it with
|
|
6
|
+
* the local python3, forwarding any CLI args. The server itself shells out to
|
|
7
|
+
* ccusage for all numbers, so the dashboard always matches ccusage.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
|
|
15
|
+
// Data files live in package/lib (installed layout) or the repo root (dev layout).
|
|
16
|
+
function resolveLibFile(name) {
|
|
17
|
+
for (const dir of [path.join(__dirname, '..', 'lib'), path.join(__dirname, '..')]) {
|
|
18
|
+
const p = path.join(dir, name);
|
|
19
|
+
if (fs.existsSync(p)) return p;
|
|
20
|
+
}
|
|
21
|
+
console.error(`[ccusage-dashboard] missing bundled file ${name} — broken install?`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const serverPy = resolveLibFile('server.py');
|
|
26
|
+
|
|
27
|
+
function findPython() {
|
|
28
|
+
for (const bin of ['python3', 'python']) {
|
|
29
|
+
try {
|
|
30
|
+
const r = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
31
|
+
if (r.status === 0) return bin;
|
|
32
|
+
} catch { /* try next */ }
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const py = findPython();
|
|
38
|
+
if (!py) {
|
|
39
|
+
console.error('[ccusage-dashboard] python3 is required but was not found on PATH.');
|
|
40
|
+
console.error('Install it with: brew install python3 (macOS)');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
if (!args.includes('--open') && !process.env.CCUSAGE_UI_NO_OPEN) {
|
|
46
|
+
args.push('--open'); // default: open browser after start
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const child = spawn(py, [serverPy, ...args], { stdio: 'inherit', env: { ...process.env } });
|
|
50
|
+
|
|
51
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
52
|
+
process.on(sig, () => { if (!child.killed) child.kill(sig); });
|
|
53
|
+
}
|
|
54
|
+
child.on('exit', (code) => process.exit(code == null ? 0 : code));
|
package/lib/index.html
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ccusage — Live Cost Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg:#0d1117; --panel:#161b22; --panel-2:#1c2330; --border:#2a3441;
|
|
10
|
+
--text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --green:#3fb950;
|
|
11
|
+
--red:#f85149; --yellow:#d29922;
|
|
12
|
+
}
|
|
13
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
14
|
+
body{background:var(--bg);color:var(--text);font:14px/1.45 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:1120px;margin:0 auto}
|
|
15
|
+
header{display:flex;align-items:baseline;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
|
16
|
+
h1{font-size:20px;font-weight:650;letter-spacing:-.2px}
|
|
17
|
+
.sub{color:var(--muted);font-size:13px}
|
|
18
|
+
.status{margin-left:auto;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
|
|
19
|
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
|
20
|
+
.dot.ok{background:var(--green);box-shadow:0 0 6px var(--green)}
|
|
21
|
+
.dot.err{background:var(--red);box-shadow:0 0 6px var(--red)}
|
|
22
|
+
.tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
|
23
|
+
.tab{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:7px 16px;cursor:pointer;color:var(--muted);font-size:13px}
|
|
24
|
+
.tab.active{background:var(--panel-2);border-color:var(--accent);color:var(--text);font-weight:600}
|
|
25
|
+
.tab:hover{color:var(--text)}
|
|
26
|
+
.tab-budget{margin-left:auto;display:flex;align-items:center;gap:10px}
|
|
27
|
+
.budget-pill{border-radius:999px;padding:4px 12px;font-size:12px;font-weight:600;border:1px solid var(--border)}
|
|
28
|
+
.budget-ok{color:var(--green);border-color:var(--border)}
|
|
29
|
+
.budget-warn{color:var(--yellow);border-color:var(--yellow)}
|
|
30
|
+
.budget-danger{color:#fff;background:var(--red);border-color:var(--red)}
|
|
31
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:22px}
|
|
32
|
+
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
|
|
33
|
+
.card .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.6px}
|
|
34
|
+
.card .value{font-size:26px;font-weight:650;margin-top:4px;font-variant-numeric:tabular-nums}
|
|
35
|
+
.card .value small{font-size:15px;color:var(--muted);font-weight:500}
|
|
36
|
+
.card .period{color:var(--muted);font-size:12px;margin-top:2px}
|
|
37
|
+
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px}
|
|
38
|
+
.panel h2{font-size:15px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:baseline}
|
|
39
|
+
.panel h2 .tot{color:var(--accent);font-weight:650;font-variant-numeric:tabular-nums}
|
|
40
|
+
.row{padding:9px 2px;border-bottom:1px solid var(--border)}
|
|
41
|
+
.row:last-child{border-bottom:none}
|
|
42
|
+
.row-top{display:flex;justify-content:space-between;gap:12px;margin-bottom:5px}
|
|
43
|
+
.row .name{font-weight:550;word-break:break-all}
|
|
44
|
+
.row .cost{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
45
|
+
.row .toks{color:var(--muted);font-size:12px;display:flex;gap:12px;flex-wrap:wrap}
|
|
46
|
+
.bar{height:5px;background:var(--panel-2);border-radius:3px;overflow:hidden;margin-top:6px}
|
|
47
|
+
.bar>div{height:100%;background:linear-gradient(90deg,var(--accent),#79c0ff);border-radius:3px}
|
|
48
|
+
.empty{color:var(--muted);padding:18px 0;text-align:center}
|
|
49
|
+
.err{color:var(--red)}
|
|
50
|
+
.pill{background:var(--panel-2);border:1px solid var(--border);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--muted);margin-left:6px}
|
|
51
|
+
.hint{color:var(--muted);font-size:12px;margin-top:14px;text-align:center}
|
|
52
|
+
.range{display:flex;gap:8px;align-items:center;margin-left:6px}
|
|
53
|
+
.range input{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 8px;font-size:12px;font-family:inherit}
|
|
54
|
+
.range button{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 12px;cursor:pointer;font-size:12px}
|
|
55
|
+
.range button:hover{border-color:var(--accent)}
|
|
56
|
+
.budget-band{margin:2px 0 14px;background:var(--panel-2);border-radius:8px;padding:12px 16px;display:flex;flex-direction:column;gap:8px}
|
|
57
|
+
.budget-band .bl{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
|
|
58
|
+
.budget-band .bl b{color:var(--text)}
|
|
59
|
+
.bgtrack{height:8px;background:#0a0e14;border-radius:5px;overflow:hidden}
|
|
60
|
+
.bgtrack>div{height:100%;border-radius:5px;transition:width .4s}
|
|
61
|
+
.bg-ok{background:var(--green)} .bg-warn{background:var(--yellow)} .bg-danger{background:var(--red)}
|
|
62
|
+
#trendChart{width:100%;height:220px}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<header>
|
|
67
|
+
<h1>ccusage · Live Cost</h1>
|
|
68
|
+
<span class="sub">by model, straight from ccusage</span>
|
|
69
|
+
<span class="status"><span class="dot" id="dot"></span><span id="status">connecting…</span></span>
|
|
70
|
+
</header>
|
|
71
|
+
|
|
72
|
+
<div class="tabs">
|
|
73
|
+
<div class="tab active" data-tab="today">Today</div>
|
|
74
|
+
<div class="tab" data-tab="week">This Week</div>
|
|
75
|
+
<div class="tab" data-tab="month">This Month</div>
|
|
76
|
+
<div class="tab" data-tab="range">Custom Range</div>
|
|
77
|
+
<div class="tab-budget">
|
|
78
|
+
<span class="budget-pill" id="budgetPill">budget …</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div id="budgetBand" class="budget-band" style="display:none">
|
|
83
|
+
<div class="bl"><span>Monthly budget: <b id="bgCap"></b></span><span id="bgUsed"></span></div>
|
|
84
|
+
<div class="bgtrack"><div id="bgFill" style="width:0%"></div></div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="cards" id="cards">
|
|
88
|
+
<div class="card"><div class="label" id="c1label">Today</div><div class="value" id="c1">–</div><div class="period" id="c1p"></div></div>
|
|
89
|
+
<div class="card"><div class="label">Tokens</div><div class="value" id="c2">–</div></div>
|
|
90
|
+
<div class="card"><div class="label">Cache Read</div><div class="value" id="c3">–</div></div>
|
|
91
|
+
<div class="card"><div class="label">Cache Write</div><div class="value" id="c4">–</div></div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div id="rangeRow" class="panel" style="display:none">
|
|
95
|
+
<h2>Custom Range <span class="tot" id="rangeSum"></span></h2>
|
|
96
|
+
<div class="range">
|
|
97
|
+
<input type="date" id="fromDate"> <span>→</span> <input type="date" id="toDate">
|
|
98
|
+
<button id="rangeGo">Apply</button>
|
|
99
|
+
<span class="pill" id="rangeDays"></span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="panel"><h2>30-Day Trend <span class="tot" id="trendSum"></span></h2><canvas id="trendChart"></canvas></div>
|
|
104
|
+
|
|
105
|
+
<div class="panel"><h2>Models <span class="tot" id="modelSum"></span></h2><div id="modelList"><div class="empty">loading…</div></div></div>
|
|
106
|
+
|
|
107
|
+
<div class="hint">auto-refresh: today 15s · others 60s · costs in USD · <span id="lastUpd"></span></div>
|
|
108
|
+
|
|
109
|
+
<script>
|
|
110
|
+
const $=(id)=>document.getElementById(id);
|
|
111
|
+
const tabs={today:{label:"Today",len:0},week:{label:"This Week",len:0},month:{label:"This Month",len:0},range:{label:"Custom Range",len:1}};
|
|
112
|
+
let active="today", modelCtx=[];
|
|
113
|
+
|
|
114
|
+
function fmtUsd(n){return "$"+(n==null?"–":Number(n).toFixed(2));}
|
|
115
|
+
function fmtTok(n){
|
|
116
|
+
if(n==null)return "–";
|
|
117
|
+
if(n>=1e6)return (n/1e6).toFixed(2)+"M";
|
|
118
|
+
if(n>=1e3)return (n/1e3).toFixed(1)+"k";
|
|
119
|
+
return String(n);
|
|
120
|
+
}
|
|
121
|
+
function esc(s){const d=document.createElement("div");d.textContent=s;return d.innerHTML;}
|
|
122
|
+
|
|
123
|
+
function renderModels(data){
|
|
124
|
+
const total=data.totalCost||0;
|
|
125
|
+
const models=data.models||[];
|
|
126
|
+
if(!models.length){$("modelList").innerHTML='<div class="empty">no usage recorded</div>';modelCtx=[];return;}
|
|
127
|
+
modelCtx=models;
|
|
128
|
+
$("modelList").innerHTML=models.map(m=>{
|
|
129
|
+
const pct=total>0?(m.cost/total)*100:0;
|
|
130
|
+
const m2=m.name.match(/^\[(.+?)\]\s*(.*)$/);
|
|
131
|
+
const badge=m2?`<span class="pill">${esc(m2[1])}</span>`:"";
|
|
132
|
+
const mn=m2?esc(m2[2]||m2[1]):esc(m.name);
|
|
133
|
+
return `<div class="row"><div class="row-top"><span class="name">${mn}${badge ? " " + badge : ""}</span><span class="cost">${fmtUsd(m.cost)} · ${pct.toFixed(1)}%</span></div>
|
|
134
|
+
<div class="toks"><span title="input">in ${fmtTok(m.inputTokens)}</span><span title="output">out ${fmtTok(m.outputTokens)}</span><span title="cache read">read ${fmtTok(m.cacheReadTokens)}</span><span title="cache write">write ${fmtTok(m.cacheCreationTokens)}</span></div>
|
|
135
|
+
<div class="bar"><div style="width:${Math.max(pct,1.2)}%"></div></div></div>`;
|
|
136
|
+
}).join("");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyBudget(month){
|
|
140
|
+
const cap=month.budget;
|
|
141
|
+
if(!cap){$("budgetBand").style.display="none";$("budgetPill").textContent="budget: —";return;}
|
|
142
|
+
const spent=month.totalCost||0, pct=month.budgetUsedPct||0;
|
|
143
|
+
$("bgCap").textContent=fmtUsd(cap);
|
|
144
|
+
$("bgUsed").textContent=`${fmtUsd(spent)} spent · ${pct.toFixed(1)}% of budget`;
|
|
145
|
+
const fill=$("bgFill");
|
|
146
|
+
fill.style.width=Math.min(pct,100)+"%";
|
|
147
|
+
fill.className = pct>=100?"bg-danger":(pct>=80?"bg-warn":"bg-ok");
|
|
148
|
+
$("budgetBand").style.display="flex";
|
|
149
|
+
let cls="budget-ok",txt=`budget ${fmtUsd(cap)} · ${pct.toFixed(0)}%`;
|
|
150
|
+
if(pct>=100){cls="budget-danger";txt=`⚠ ${fmtUsd(spent)} ≥ budget`;}
|
|
151
|
+
else if(pct>=80){cls="budget-warn";txt=`${fmtUsd(spent)} / ${fmtUsd(cap)} (${pct.toFixed(0)}%)`;}
|
|
152
|
+
const p=$("budgetPill");p.className="budget-pill "+cls;p.textContent=txt;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyCards(data){
|
|
156
|
+
const total=data.totalCost||0;
|
|
157
|
+
$("c1").innerHTML=fmtUsd(total)+(total?"<small> USD</small>":"");
|
|
158
|
+
$("c1p").textContent=data.period||"";
|
|
159
|
+
const tt=(data.inputTokens||0)+(data.outputTokens||0)+(data.cacheReadTokens||0)+(data.cacheCreationTokens||0);
|
|
160
|
+
$("c2").textContent=fmtTok(tt);
|
|
161
|
+
$("c3").textContent=fmtTok(data.cacheReadTokens);
|
|
162
|
+
$("c4").textContent=fmtTok(data.cacheCreationTokens);
|
|
163
|
+
$("c1label").textContent=tabs[active].label;
|
|
164
|
+
$("modelSum").textContent=fmtUsd(total);
|
|
165
|
+
}
|
|
166
|
+
function applyCardsRange(data){
|
|
167
|
+
const p=$("rangeSum");
|
|
168
|
+
p.textContent=fmtUsd(data.totalCost||0);
|
|
169
|
+
$("rangeDays").textContent=data.dayCount?`${data.dayCount} days`:"";
|
|
170
|
+
applyCards(data);
|
|
171
|
+
renderModels(data);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function loadModels(){
|
|
175
|
+
let ep,data;
|
|
176
|
+
if(active==="today"){ep="/api/today";data=await (await fetch(ep)).json();}
|
|
177
|
+
else if(active==="week"){data=await (await fetch("/api/week")).json();}
|
|
178
|
+
else if(active==="month"){data=await (await fetch("/api/month")).json();applyBudget(data);}
|
|
179
|
+
else{return;} // handled by rangeApply
|
|
180
|
+
if(data.error)throw new Error(data.error);
|
|
181
|
+
applyCards(data);
|
|
182
|
+
renderModels(data);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function rangeApply(){
|
|
186
|
+
const f=$("fromDate").value,t=$("toDate").value;
|
|
187
|
+
if(!f||!t){return;}
|
|
188
|
+
$("modelList").innerHTML='<div class="empty">loading range…</div>';
|
|
189
|
+
const data=await(await fetch(`/api/range?from=${f}&to=${t}`)).json();
|
|
190
|
+
if(data.error)throw new Error(data.error);
|
|
191
|
+
applyCardsRange(data);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function loadTrend(){
|
|
195
|
+
const d=await(await fetch("/api/trend?days=30")).json();
|
|
196
|
+
if(d.error){return;}
|
|
197
|
+
drawTrend(d.days||[]);
|
|
198
|
+
$("trendSum").textContent=fmtUsd((d.days||[]).reduce((a,x)=>a+(x.totalCost||0),0));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function drawTrend(days){
|
|
202
|
+
const cv=$("trendChart"),ctx=cv.getContext("2d");
|
|
203
|
+
const W=(cv.width=cv.offsetWidth)||900,H=(cv.height=220);
|
|
204
|
+
ctx.clearRect(0,0,W,H);
|
|
205
|
+
if(!days.length){ctx.fillStyle="#8b949e";ctx.fillText("no data",10,20);return;}
|
|
206
|
+
const max=Math.max(...days.map(x=>x.totalCost),0.0001);
|
|
207
|
+
const padL=54,padB=24,padT=12;
|
|
208
|
+
const iw=W-padL-10,ih=H-padB-padT;
|
|
209
|
+
const step=iw/days.length;
|
|
210
|
+
const bw=Math.max(2,step*0.6);
|
|
211
|
+
// grid + y labels
|
|
212
|
+
ctx.font="11px -apple-system,sans-serif";
|
|
213
|
+
for(let i=0;i<=4;i++){const y=padT+ih-(ih*(i/4));ctx.strokeStyle="#21262d";ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-10,y);ctx.stroke();ctx.fillStyle="#8b949e";ctx.textAlign="right";ctx.fillText(fmtUsd(max*(i/4)),padL-6,y+4);}
|
|
214
|
+
// bars
|
|
215
|
+
days.forEach((d,i)=>{
|
|
216
|
+
const h=Math.max(1,(d.totalCost/max)*ih);
|
|
217
|
+
const x=padL+step*i+(step-bw)/2;
|
|
218
|
+
const y=padT+ih-h;
|
|
219
|
+
ctx.fillStyle=d.totalCost>0?"#58a6ff":"#21262d";
|
|
220
|
+
ctx.beginPath();ctx.roundRect?ctx.roundRect(x,y,bw,h,3):ctx.rect(x,y,bw,h);ctx.fill();
|
|
221
|
+
// weekend dot
|
|
222
|
+
const dt=new Date(d.period+"T00:00:00");
|
|
223
|
+
const day=dt.getDay();
|
|
224
|
+
if(day===0||day===6){ctx.fillStyle="rgba(210,153,34,.85)";ctx.beginPath();ctx.arc(x+bw/2,padT+ih+5,2.5,0,7);ctx.fill();}
|
|
225
|
+
});
|
|
226
|
+
// x labels (every ~5th)
|
|
227
|
+
ctx.fillStyle="#8b949e";ctx.textAlign="center";
|
|
228
|
+
const every=Math.max(1,Math.floor(days.length/10));
|
|
229
|
+
days.forEach((d,i)=>{
|
|
230
|
+
if(i%every!==0&&i!==days.length-1)return;
|
|
231
|
+
const x=padL+step*i+step/2;
|
|
232
|
+
ctx.fillText(d.period.slice(5),x,H-6);
|
|
233
|
+
});
|
|
234
|
+
// tooltip via hover (simple)
|
|
235
|
+
cv.onmousemove=(e)=>{
|
|
236
|
+
const r=cv.getBoundingClientRect();const mx=e.clientX-r.left;
|
|
237
|
+
const idx=Math.min(days.length-1,Math.max(0,Math.floor((mx-padL)/step)));
|
|
238
|
+
const d=days[idx];if(!d)return;
|
|
239
|
+
ctx.clearRect(0,0,W,H);drawTrend(days);
|
|
240
|
+
ctx.fillStyle="rgba(22,27,34,.95)";const tx=Math.min(mx+10,W-120),ty=12;
|
|
241
|
+
ctx.beginPath();ctx.roundRect?ctx.roundRect(tx,ty,110,28,6):ctx.rect(tx,ty,110,28);ctx.fill();
|
|
242
|
+
ctx.fillStyle="#e6edf3";ctx.font="12px -apple-system,sans-serif";ctx.textAlign="left";
|
|
243
|
+
ctx.fillText(`${d.period}: ${fmtUsd(d.totalCost)}`,tx+8,ty+19);
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function tick(){
|
|
248
|
+
const dot=$("dot"),st=$("status");
|
|
249
|
+
try{
|
|
250
|
+
const month=await(await fetch("/api/month")).json();
|
|
251
|
+
if(month.error)throw new Error(month.error);
|
|
252
|
+
applyBudget(month);
|
|
253
|
+
await loadModels();
|
|
254
|
+
await loadTrend();
|
|
255
|
+
dot.className="dot ok";st.textContent="live";
|
|
256
|
+
$("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
|
|
257
|
+
}catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
document.querySelectorAll(".tab").forEach(t=>{
|
|
261
|
+
t.addEventListener("click",()=>{
|
|
262
|
+
document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
|
|
263
|
+
t.classList.add("active");
|
|
264
|
+
active=t.dataset.tab;
|
|
265
|
+
$("rangeRow").style.display = active==="range"?"block":"none";
|
|
266
|
+
if(active==="range"){
|
|
267
|
+
if(!$("fromDate").value){const e=new Date();$("toDate").value=e.toISOString().slice(0,10);const s=new Date();s.setDate(s.getDate()-6);$("fromDate").value=s.toISOString().slice(0,10);}
|
|
268
|
+
rangeApply();
|
|
269
|
+
}else{
|
|
270
|
+
loadModels();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
$("rangeGo").addEventListener("click",rangeApply);
|
|
275
|
+
|
|
276
|
+
tick();
|
|
277
|
+
setInterval(tick,15000);
|
|
278
|
+
window.addEventListener("resize",()=>{if($("trendChart").width)loadTrend();});
|
|
279
|
+
</script>
|
|
280
|
+
</body>
|
|
281
|
+
</html>
|
package/lib/server.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ccusage-ui — a tiny zero-dependency local dashboard for ccusage.
|
|
4
|
+
|
|
5
|
+
It shells out to ccusage's JSON reports and renders:
|
|
6
|
+
- today / this week / this month / custom range, grouped by model
|
|
7
|
+
- a 30-day cost trend chart
|
|
8
|
+
- a monthly budget alert (default cap: $300)
|
|
9
|
+
|
|
10
|
+
Because all numbers come from ccusage itself, the figures always match what
|
|
11
|
+
`ccusage` reports (the source you already trust). No network, no pricing table
|
|
12
|
+
to maintain, no third-party packages — only the Python standard library.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 server.py [--port 8799] [--budget 300] [--open]
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from datetime import date, timedelta
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
APP_DIR = Path(__file__).resolve().parent
|
|
32
|
+
|
|
33
|
+
# Cache: args-key -> (expires_at, data)
|
|
34
|
+
_cache: dict[str, tuple[float, object]] = {}
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_ccusage() -> list[str]:
|
|
39
|
+
"""Resolve a fast local ccusage entrypoint.
|
|
40
|
+
|
|
41
|
+
Prefer a directly invocable local binary/cache so we don't pay an `npx`
|
|
42
|
+
resolution + possible network fetch on every report (~5-7s cold). Look for,
|
|
43
|
+
in order: an explicit --ccusage-path, a global `ccusage` on PATH, the npm
|
|
44
|
+
npx cache, the bun cache, then fall back to `npx --yes ccusage@latest`.
|
|
45
|
+
Returns a command list to run.
|
|
46
|
+
"""
|
|
47
|
+
def _npx_cached_cli() -> str | None:
|
|
48
|
+
for base in ("npm", "bun"):
|
|
49
|
+
roots = []
|
|
50
|
+
if base == "npm":
|
|
51
|
+
target = Path.home() / ".npm/_npx"
|
|
52
|
+
if target.is_dir():
|
|
53
|
+
roots.extend(p for p in target.glob("*/node_modules/ccusage/src/cli.js"))
|
|
54
|
+
# also newer npm layout
|
|
55
|
+
roots.extend((Path.home() / ".npm/_npx").glob("*/node_modules/ccusage/src/cli.js"))
|
|
56
|
+
else:
|
|
57
|
+
target = Path.home() / ".bun/install/cache"
|
|
58
|
+
if target.is_dir():
|
|
59
|
+
roots.extend(p for p in target.glob("ccusage*/src/cli.js"))
|
|
60
|
+
# prefer highest semver-ish (sort by version suffix descending)
|
|
61
|
+
def _ver(p: Path) -> tuple:
|
|
62
|
+
s = p.as_posix()
|
|
63
|
+
m = re.search(r"ccusage@?(\d+)\.(\d+)\.(\d+)", s)
|
|
64
|
+
return tuple(map(int, m.groups())) if m else (0, 0, 0)
|
|
65
|
+
best = max(roots, key=_ver, default=None)
|
|
66
|
+
if best:
|
|
67
|
+
return str(best)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
explicit = _CCUSAGE_PATH_OVERRIDE
|
|
71
|
+
if explicit:
|
|
72
|
+
return [explicit]
|
|
73
|
+
p = shutil.which("ccusage")
|
|
74
|
+
if p:
|
|
75
|
+
return [p]
|
|
76
|
+
# Bundled ccusage dependency: walk up from the package to find node_modules/ccusage
|
|
77
|
+
for parent in APP_DIR.parents:
|
|
78
|
+
bundled = parent / "node_modules" / "ccusage" / "src" / "cli.js"
|
|
79
|
+
if bundled.exists():
|
|
80
|
+
return [_node(), str(bundled)]
|
|
81
|
+
cli = _npx_cached_cli()
|
|
82
|
+
if cli:
|
|
83
|
+
return [_node(), str(cli)]
|
|
84
|
+
return ["npx", "--yes", "ccusage@latest"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_CCUSAGE_PATH_OVERRIDE: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _node() -> str:
|
|
91
|
+
import shutil as _s
|
|
92
|
+
|
|
93
|
+
n = _s.which("node") or "node"
|
|
94
|
+
return n
|
|
95
|
+
|
|
96
|
+
BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
|
|
97
|
+
TTL = {"/api/today": 15, "/api/week": 60, "/api/month": 60, "/api/range": 120, "/api/trend": 120}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_ccusage(args: list[str], ttl: float) -> dict:
|
|
101
|
+
key = " ".join(args)
|
|
102
|
+
now = time.time()
|
|
103
|
+
with _lock:
|
|
104
|
+
hit = _cache.get(key)
|
|
105
|
+
if hit and hit[0] > now:
|
|
106
|
+
return hit[1] # type: ignore[return-value]
|
|
107
|
+
try:
|
|
108
|
+
proc = subprocess.run(
|
|
109
|
+
resolve_ccusage() + args,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
timeout=120,
|
|
113
|
+
)
|
|
114
|
+
data = json.loads(proc.stdout)
|
|
115
|
+
except Exception as exc: # noqa: BLE001
|
|
116
|
+
data = {"error": f"{exc}"}
|
|
117
|
+
with _lock:
|
|
118
|
+
_cache[key] = (now + ttl, data)
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def pick_all_row(rows: list[dict]) -> dict:
|
|
123
|
+
return next((r for r in rows if r.get("agent") == "all"), None) or (rows[0] if rows else {})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def summarize(row: dict) -> dict:
|
|
127
|
+
models = sorted(row.get("modelBreakdowns", []), key=lambda m: -m.get("cost", 0))
|
|
128
|
+
total_from_models = sum(m.get("cost", 0) for m in models)
|
|
129
|
+
return {
|
|
130
|
+
"period": row.get("period") or row.get("date"),
|
|
131
|
+
"totalCost": round(row.get("totalCost", total_from_models) or 0, 4),
|
|
132
|
+
"inputTokens": row.get("inputTokens", 0),
|
|
133
|
+
"outputTokens": row.get("outputTokens", 0),
|
|
134
|
+
"cacheReadTokens": row.get("cacheReadTokens", 0),
|
|
135
|
+
"cacheCreationTokens": row.get("cacheCreationTokens", 0),
|
|
136
|
+
"models": [
|
|
137
|
+
{
|
|
138
|
+
"name": m.get("modelName") or m.get("name") or "?",
|
|
139
|
+
"cost": round(m.get("cost", 0), 4),
|
|
140
|
+
"inputTokens": m.get("inputTokens", 0),
|
|
141
|
+
"outputTokens": m.get("outputTokens", 0),
|
|
142
|
+
"cacheReadTokens": m.get("cacheReadTokens", 0),
|
|
143
|
+
"cacheCreationTokens": m.get("cacheCreationTokens", 0),
|
|
144
|
+
}
|
|
145
|
+
for m in models
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def aggregate_range(data: dict, from_date: str, to_date: str) -> dict:
|
|
151
|
+
"""Merge per-day rows (daily range report) into one period summary."""
|
|
152
|
+
rows = data.get("daily", []) or []
|
|
153
|
+
agg: dict[str, dict] = {}
|
|
154
|
+
total_cost = 0.0
|
|
155
|
+
keys = ("cost", "inputTokens", "outputTokens", "cacheReadTokens", "cacheCreationTokens")
|
|
156
|
+
for r in rows:
|
|
157
|
+
if r.get("agent") != "all":
|
|
158
|
+
continue
|
|
159
|
+
total_cost += r.get("totalCost", 0) or 0
|
|
160
|
+
for m in r.get("modelBreakdowns", []):
|
|
161
|
+
name = m.get("modelName") or "?"
|
|
162
|
+
t = agg.setdefault(name, {"name": name, **{k: 0 for k in keys}})
|
|
163
|
+
for k in keys:
|
|
164
|
+
t[k] += m.get(k, 0) or 0
|
|
165
|
+
row = pick_all_row(rows)
|
|
166
|
+
models = sorted(agg.values(), key=lambda m: -m["cost"])
|
|
167
|
+
return {
|
|
168
|
+
"period": f"{from_date} → {to_date}",
|
|
169
|
+
"totalCost": round(total_cost, 4),
|
|
170
|
+
"inputTokens": sum(r.get("inputTokens", 0) for r in rows if r.get("agent") == "all"),
|
|
171
|
+
"outputTokens": sum(r.get("outputTokens", 0) for r in rows if r.get("agent") == "all"),
|
|
172
|
+
"cacheReadTokens": sum(r.get("cacheReadTokens", 0) for r in rows if r.get("agent") == "all"),
|
|
173
|
+
"cacheCreationTokens": sum(r.get("cacheCreationTokens", 0) for r in rows if r.get("agent") == "all"),
|
|
174
|
+
"models": models,
|
|
175
|
+
"dayCount": len([r for r in rows if r.get("agent") == "all"]),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def trend(days: int = 30) -> dict:
|
|
180
|
+
end = date.today()
|
|
181
|
+
start = end - timedelta(days=days - 1)
|
|
182
|
+
data = run_ccusage(
|
|
183
|
+
["daily", "--since", start.isoformat(), "--until", end.isoformat(), "--json", "--offline"],
|
|
184
|
+
TTL["/api/trend"],
|
|
185
|
+
)
|
|
186
|
+
rows = data.get("daily", []) or []
|
|
187
|
+
by_period = {r.get("period"): (r.get("totalCost", 0) or 0) for r in rows if r.get("agent") == "all"}
|
|
188
|
+
out, d = [], start
|
|
189
|
+
while d <= end:
|
|
190
|
+
iso = d.isoformat()
|
|
191
|
+
out.append({"period": iso, "totalCost": round(by_period.get(iso, 0.0), 4)})
|
|
192
|
+
d += timedelta(days=1)
|
|
193
|
+
return {"start": start.isoformat(), "end": end.isoformat(), "days": out}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Handler(BaseHTTPRequestHandler):
|
|
197
|
+
server_version = "ccusage-ui/0.2"
|
|
198
|
+
|
|
199
|
+
def log_message(self, fmt, *args): # quieter logs
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
|
203
|
+
self.send_response(code)
|
|
204
|
+
self.send_header("Content-Type", content_type)
|
|
205
|
+
self.send_header("Content-Length", str(len(body)))
|
|
206
|
+
self.send_header("Cache-Control", "no-store")
|
|
207
|
+
self.end_headers()
|
|
208
|
+
self.wfile.write(body)
|
|
209
|
+
|
|
210
|
+
def _json(self, obj: dict) -> None:
|
|
211
|
+
self._send(200, json.dumps(obj).encode(), "application/json")
|
|
212
|
+
|
|
213
|
+
def do_GET(self): # noqa: N802
|
|
214
|
+
path, _, query = self.path.partition("?")
|
|
215
|
+
params = dict(p.split("=", 1) for p in query.split("&") if "=" in p)
|
|
216
|
+
|
|
217
|
+
if path in ("/", "/index.html"):
|
|
218
|
+
self._send(200, (APP_DIR / "index.html").read_bytes(), "text/html; charset=utf-8")
|
|
219
|
+
return
|
|
220
|
+
if path == "/api/health":
|
|
221
|
+
self._json({"ok": True, "budget": BUDGET})
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if path == "/api/today":
|
|
225
|
+
data = run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL[path])
|
|
226
|
+
rows_key, summary = "daily", summarize(pick_all_row(data.get("daily", [])))
|
|
227
|
+
elif path == "/api/week":
|
|
228
|
+
data = run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL[path])
|
|
229
|
+
summary = summarize(pick_all_row(data.get("weekly", [])))
|
|
230
|
+
elif path == "/api/month":
|
|
231
|
+
data = run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL[path])
|
|
232
|
+
summary = summarize(pick_all_row(data.get("monthly", [])))
|
|
233
|
+
summary["budget"] = BUDGET
|
|
234
|
+
summary["budgetUsedPct"] = round((summary["totalCost"] / BUDGET) * 100, 1) if BUDGET else 0
|
|
235
|
+
elif path == "/api/range":
|
|
236
|
+
frm, to = params.get("from", ""), params.get("to", "")
|
|
237
|
+
if not frm or not to:
|
|
238
|
+
self._json({"error": "from/to required (YYYY-MM-DD)"})
|
|
239
|
+
return
|
|
240
|
+
data = run_ccusage(["daily", "--since", frm, "--until", to, "--json", "--offline"], TTL[path])
|
|
241
|
+
summary = aggregate_range(data, frm, to)
|
|
242
|
+
elif path == "/api/trend":
|
|
243
|
+
days = max(1, min(366, int(params.get("days", "30"))))
|
|
244
|
+
self._json(trend(days))
|
|
245
|
+
return
|
|
246
|
+
else:
|
|
247
|
+
self._send(404, b"not found", "text/plain")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if isinstance(data, dict) and data.get("error"):
|
|
251
|
+
self._json({"error": data["error"]})
|
|
252
|
+
return
|
|
253
|
+
self._json(summary)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main() -> None:
|
|
257
|
+
global BUDGET, _CCUSAGE_PATH_OVERRIDE
|
|
258
|
+
parser = argparse.ArgumentParser(description="ccusage dashboard")
|
|
259
|
+
parser.add_argument("--port", type=int, default=8799)
|
|
260
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
261
|
+
parser.add_argument("--budget", type=float, default=None, help="monthly budget cap in USD (default 300)")
|
|
262
|
+
parser.add_argument("--ccusage-path", default=None, help="explicit path to a ccusage binary or src/cli.js")
|
|
263
|
+
parser.add_argument("--no-warm", action="store_true", help="skip blocking warm-up (first requests may be slow)")
|
|
264
|
+
parser.add_argument("--open", action="store_true", help="open browser after start")
|
|
265
|
+
args = parser.parse_args()
|
|
266
|
+
|
|
267
|
+
BUDGET = args.budget if args.budget is not None else float(os_env_budget() or 300.0)
|
|
268
|
+
_CCUSAGE_PATH_OVERRIDE = args.ccusage_path
|
|
269
|
+
print(f"using ccusage → {' '.join(resolve_ccusage())}")
|
|
270
|
+
|
|
271
|
+
def warm_one(fn):
|
|
272
|
+
fn()
|
|
273
|
+
|
|
274
|
+
def warm():
|
|
275
|
+
threads = [
|
|
276
|
+
threading.Thread(target=warm_one, args=(lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),)),
|
|
277
|
+
threading.Thread(target=warm_one, args=(lambda: run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL["/api/week"]),)),
|
|
278
|
+
threading.Thread(target=warm_one, args=(lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),)),
|
|
279
|
+
threading.Thread(target=warm_one, args=(lambda: trend(30),)),
|
|
280
|
+
]
|
|
281
|
+
for t in threads:
|
|
282
|
+
t.start()
|
|
283
|
+
for t in threads:
|
|
284
|
+
t.join()
|
|
285
|
+
|
|
286
|
+
if not args.no_warm:
|
|
287
|
+
print("warming ccusage caches (first load will be instant after this)…", flush=True)
|
|
288
|
+
warm()
|
|
289
|
+
print("warm-up complete.")
|
|
290
|
+
|
|
291
|
+
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
292
|
+
print(f"ccusage-ui → http://{args.host}:{args.port} (monthly budget ${BUDGET:g}, Ctrl+C to stop)")
|
|
293
|
+
if args.open:
|
|
294
|
+
import webbrowser
|
|
295
|
+
|
|
296
|
+
webbrowser.open(f"http://{args.host}:{args.port}")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
httpd.serve_forever()
|
|
300
|
+
except KeyboardInterrupt:
|
|
301
|
+
print("\nbye")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def os_env_budget() -> str:
|
|
305
|
+
import os
|
|
306
|
+
|
|
307
|
+
return os.environ.get("CCUSAGE_BUDGET", "")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@caius_kong/ccusage-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command local dashboard for ccusage: today/week/month/custom cost by model, 30-day trend, monthly budget alert. Numbers straight from ccusage.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ccusage-dashboard": "bin/ccusage-ui.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=16"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ccusage",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"codex",
|
|
21
|
+
"cost",
|
|
22
|
+
"usage",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"llm-cost",
|
|
25
|
+
"token-usage"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node bin/ccusage-ui.js"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/caius-kong/ccusage-dashboard"
|
|
33
|
+
},
|
|
34
|
+
"author": "Caius Kong",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"ccusage": "^20.0.20"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|