@jnst/cursor-usage 0.3.0 → 0.5.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 CHANGED
@@ -19,7 +19,7 @@ npx @jnst/cursor-usage # or: bunx @jnst/cursor-usage
19
19
 
20
20
  Starts a local server and opens your browser. Drag & drop a CSV exported from Cursor onto the page. All data is processed in the browser and never sent anywhere.
21
21
 
22
- Click any bar in the daily cost chart to drill into that day (hourly breakdown, per-model / per-user / per-kind costs, and every event of the day). Click a user bar to filter the current analysis to that User; the selected User remains visible while other users are dimmed, and clicking the selected user again clears the filter. The selected day, user, and analysis time zone are reflected in the URL hash (`#day=YYYY-MM-DD&user=jnst%40example.jp&timezone=Asia%2FTokyo`), so the browser back button and shareable links work after loading the same CSV.
22
+ Click any bar in the Daily Window cost chart to drill into that window (hourly breakdown, per-model / per-user / per-kind costs, and every event in the window). Click a user bar to filter the current analysis to that User; the selected User remains visible while other users are dimmed, and clicking the selected user again clears the filter. The selected Daily Window, user, and analysis time zone are reflected in the URL hash (`#daily-window=YYYY-MM-DD&user=jnst%40example.jp&timezone=Asia%2FTokyo`), so the browser back button and shareable links work after loading the same CSV.
23
23
 
24
24
  The default port is 4321; if it is already in use, a free port is picked automatically. When `--port` is specified explicitly, that port is used as-is.
25
25
 
@@ -34,13 +34,13 @@ npx @jnst/cursor-usage stats team-usage-events.csv
34
34
  ```
35
35
 
36
36
  ```
37
- Cursor Usage 2026-06-01 – 2026-06-10 (610 events, 10 days)
37
+ Cursor Usage 2026-06-01 – 2026-06-10 (610 events, 10 daily windows)
38
38
 
39
39
  Total Cost $1446.69 Total Tokens 1.1B
40
40
  Avg/Active $144.67 Max Mode 96%
41
41
  Users 4 Models 8
42
42
 
43
- Daily Cost
43
+ Daily Window Cost
44
44
  2026-06-01 $147.44 ████████████████▊ 10% 102.9M tok, 68 ev
45
45
  2026-06-02 $246.57 ████████████████████████████ 17% 180.0M tok, 79 ev
46
46
  ...
@@ -53,21 +53,22 @@ By Model
53
53
 
54
54
  Options:
55
55
 
56
- | Option | Description |
57
- | ------------------------- | --------------------------------------------------------------- |
58
- | `--by <day\|user\|model>` | Show a single breakdown axis |
59
- | `--day <YYYY-MM-DD>` | Drill into a single day (hourly, model, user, kind, top events) |
60
- | `--user <identifier>` | Filter analysis to a single User |
61
- | `--timezone <iana-tz>` | Group days and hours in a specific analysis time zone |
62
- | `--json` | Output aggregated stats as JSON (pipe to jq etc.) |
63
- | `--include-no-charge` | Include "Errored, No Charge" events |
56
+ | Option | Description |
57
+ | ---------------------------------- | -------------------------------------------------------------- |
58
+ | `--by <daily-window\|user\|model>` | Show a single breakdown axis |
59
+ | `--daily-window <YYYY-MM-DD>` | Drill into a single Daily Window |
60
+ | `--start-hour <0-23>` | Daily Window start hour (default: `0`) |
61
+ | `--user <identifier>` | Filter analysis to a single User |
62
+ | `--timezone <iana-tz>` | Group Daily Windows and hours in a specific analysis time zone |
63
+ | `--json` | Output aggregated stats as JSON (pipe to jq etc.) |
64
+ | `--include-no-charge` | Include "Errored, No Charge" events |
64
65
 
65
66
  ```bash
66
67
  # Extract key numbers
67
68
  npx @jnst/cursor-usage stats usage.csv --json | jq .summary.totalCost
68
69
 
69
- # Drill into the most expensive day
70
- npx @jnst/cursor-usage stats usage.csv --day 2026-06-02 --timezone Asia/Tokyo
70
+ # Drill into a Daily Window
71
+ npx @jnst/cursor-usage stats usage.csv --daily-window 2026-06-02 --timezone Asia/Tokyo
71
72
 
72
73
  # Filter to a single user
73
74
  npx @jnst/cursor-usage stats usage.csv --user jnst@example.jp
@@ -80,6 +81,54 @@ npm install -g @jnst/cursor-usage # or: bun add -g @jnst/cursor-usage
80
81
  cursor-usage stats usage.csv
81
82
  ```
82
83
 
84
+ ### Screenshots
85
+
86
+ ```bash
87
+ npx @jnst/cursor-usage screenshot team-usage-events.csv
88
+ ```
89
+
90
+ Captures the dashboard as a PNG next to the CSV. The default screenshot is an
91
+ Overview:
92
+
93
+ ```text
94
+ team-usage-events.csv -> team-usage-events.png
95
+ ```
96
+
97
+ Use `--daily-window` to capture the detail view for one Daily Window:
98
+
99
+ ```bash
100
+ npx @jnst/cursor-usage screenshot team-usage-events.csv --daily-window 2026-06-14
101
+ ```
102
+
103
+ ```text
104
+ team-usage-events.csv --daily-window 2026-06-14 -> team-usage-events-2026-06-14-daily.png
105
+ ```
106
+
107
+ Use `--start-hour` when a Daily Window should start after midnight, and
108
+ `--event-limit` to limit the event table in the screenshot:
109
+
110
+ ```bash
111
+ npx @jnst/cursor-usage screenshot usage.csv --daily-window 2026-06-14 --start-hour 5 --event-limit 20
112
+ ```
113
+
114
+ For a shareable report of the latest work session in the CSV, use
115
+ `daily-report`. It captures the latest 5:00-start Daily Window, limits the
116
+ event table to the top 10 events by cost, and writes `daily-report.png` in the
117
+ current directory:
118
+
119
+ ```bash
120
+ npx @jnst/cursor-usage daily-report usage.csv
121
+ ```
122
+
123
+ Screenshots use a headless browser and require an installed Chrome/Chromium.
124
+ Set `CHROME_PATH` if Chrome is not available on the default channel. Screenshots
125
+ can also be filtered the same way as terminal stats:
126
+
127
+ ```bash
128
+ npx @jnst/cursor-usage screenshot usage.csv --daily-window 2026-06-14 --user jnst@example.jp --timezone Asia/Tokyo
129
+ npx @jnst/cursor-usage screenshot usage.csv --out dashboard.png
130
+ ```
131
+
83
132
  ## Development
84
133
 
85
134
  Development tooling uses [Bun](https://bun.sh) (runtime code itself is Node-compatible).
@@ -97,32 +146,27 @@ bun scripts/generate-dummy-csv.ts > dummy-usage.csv
97
146
 
98
147
  ### Release
99
148
 
100
- Release commands verify, version, publish, push commits/tags, and create a
101
- draft GitHub Release with generated notes:
149
+ The release command verifies, versions, publishes, pushes commits/tags, and creates a
150
+ GitHub Release with generated notes:
102
151
 
103
152
  ```bash
104
- bun run release:patch
105
- bun run release:minor
106
- bun run release:major
153
+ bun run release
107
154
  ```
108
155
 
109
- GitHub Releases are drafts by default so notes can be reviewed before publish.
110
- Use `--publish-release` to publish the GitHub Release immediately, or
111
- `--dry-run` to print mutating steps without running them:
156
+ Use `--dry-run` to print mutating steps without running them:
112
157
 
113
158
  ```bash
114
- bun run release:patch --dry-run
115
- bun run release:patch --publish-release
159
+ bun run release --dry-run
116
160
  ```
117
161
 
118
- Release commands are safe to rerun after a partial failure. The script checks
162
+ The release command is safe to rerun after a partial failure. The script checks
119
163
  the current tag, npm package version, and GitHub Release before each publishing
120
164
  step:
121
165
 
122
166
  ```bash
123
167
  # If npm publish, git push, or GitHub Release creation failed midway,
124
168
  # fix the problem and run the same command again.
125
- bun run release:patch
169
+ bun run release
126
170
  ```
127
171
 
128
172
  ## Architecture
package/dist/cli.js CHANGED
@@ -1,28 +1,42 @@
1
1
  #!/usr/bin/env node
2
- import{readFile as Wg}from"node:fs/promises";import{parseArgs as m}from"node:util";var k="Errored, No Charge";var F="UTC";function w(){return Intl.DateTimeFormat().resolvedOptions().timeZone||F}function h(g){try{return new Intl.DateTimeFormat("en-US",{timeZone:g}).format(new Date),!0}catch{return!1}}function M(g,f,$){return new Intl.DateTimeFormat("en-GB",{timeZone:f,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",hourCycle:"h23"}).formatToParts(g).find((J)=>J.type===$)?.value??""}function x(g,f=F){return[M(g,f,"year"),M(g,f,"month"),M(g,f,"day")].join("-")}function a(g,f=F){return M(g,f,"hour")}function D(g,f,$=F){return g.filter((V)=>x(V.date,$)===f)}function E(g){return g.filter((f)=>f.kind!==k)}function K(g,f=F){let $=0,V=0,J=0,S=new Set,Y=new Set,A=new Set;for(let G of g){if($+=G.cost,V+=G.totalTokens,G.maxMode)J++;S.add(x(G.date,f)),Y.add(G.user),A.add(G.model)}let j=[...S].sort();return{totalCost:$,totalTokens:V,eventCount:g.length,firstDay:j[0]??null,lastDay:j[j.length-1]??null,dayCount:S.size,avgCostPerActiveDay:S.size>0?$/S.size:0,maxModeRatio:g.length>0?J/g.length:0,userCount:Y.size,modelCount:A.size}}function P(g,f){let $=new Map;for(let V of g){let J=f(V),S=$.get(J);if(!S)S={key:J,cost:0,totalTokens:0,inputTokens:0,outputTokens:0,cacheRead:0,eventCount:0},$.set(J,S);S.cost+=V.cost,S.totalTokens+=V.totalTokens,S.inputTokens+=V.inputWithCacheWrite+V.inputWithoutCacheWrite,S.outputTokens+=V.outputTokens,S.cacheRead+=V.cacheRead,S.eventCount++}return[...$.values()]}function O(g,f=F){return P(g,($)=>x($.date,f)).sort(($,V)=>$.key.localeCompare(V.key))}function N(g){return P(g,(f)=>f.user).sort((f,$)=>$.cost-f.cost)}function B(g){return P(g,(f)=>f.model).sort((f,$)=>$.cost-f.cost)}function c(g){return P(g,(f)=>f.kind).sort((f,$)=>$.cost-f.cost)}function T(g,f=F){return P(g,($)=>a($.date,f)).sort(($,V)=>$.key.localeCompare(V.key))}function l(g,f){return[...g].sort(($,V)=>V.cost-$.cost).slice(0,f)}function t(g){let f=[],$=[],V="",J=!1,S=0,Y=()=>{$.push(V),V=""},A=()=>{Y(),f.push($),$=[]};while(S<g.length){let j=g[S];if(J){if(j==='"'){if(g[S+1]==='"'){V+='"',S+=2;continue}J=!1,S++;continue}V+=j,S++;continue}if(j==='"'){J=!0,S++;continue}if(j===","){Y(),S++;continue}if(j==="\r"){S++;continue}if(j===`
3
- `){A(),S++;continue}V+=j,S++}if(V.length>0||$.length>0)A();return f}var e=["Date","User","Cloud Agent ID","Automation ID","Kind","Model","Max Mode","Input (w/ Cache Write)","Input (w/o Cache Write)","Cache Read","Output Tokens","Total Tokens","Cost"];function H(g){if(!g)return 0;let f=Number(g);return Number.isFinite(f)?f:0}function Z(g){let f=t(g.trim());if(f.length===0)return[];let $=f[0],V=new Map;$.forEach((Y,A)=>V.set(Y.trim(),A));for(let Y of["Date","User","Model","Cost"])if(!V.has(Y))throw Error(`Invalid CSV: missing column "${Y}". Expected a Cursor usage-events export with columns: ${e.join(", ")}`);let J=(Y,A)=>{let j=V.get(A);return j===void 0?"":(Y[j]??"").trim()},S=[];for(let Y of f.slice(1)){if(Y.length===1&&Y[0]==="")continue;let A=J(Y,"Date"),j=new Date(A);if(Number.isNaN(j.getTime()))continue;S.push({date:j,user:J(Y,"User"),cloudAgentId:J(Y,"Cloud Agent ID")||null,automationId:J(Y,"Automation ID")||null,kind:J(Y,"Kind"),model:J(Y,"Model"),maxMode:J(Y,"Max Mode").toLowerCase()==="yes",inputWithCacheWrite:H(J(Y,"Input (w/ Cache Write)")),inputWithoutCacheWrite:H(J(Y,"Input (w/o Cache Write)")),cacheRead:H(J(Y,"Cache Read")),outputTokens:H(J(Y,"Output Tokens")),totalTokens:H(J(Y,"Total Tokens")),cost:H(J(Y,"Cost"))})}return S}import{spawn as gg}from"node:child_process";import{createReadStream as fg,existsSync as $g}from"node:fs";import{stat as Sg}from"node:fs/promises";import{createServer as Yg}from"node:http";import{dirname as Jg,extname as Vg,join as p,normalize as Ag}from"node:path";import{fileURLToPath as jg}from"node:url";var u=4321,Gg={".html":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".map":"application/json"};function Xg(){let g=Jg(jg(import.meta.url)),f=[p(g,"web"),p(g,"../../dist/web")];for(let $ of f)if($g(p($,"index.html")))return $;throw Error("Dashboard assets not found. Run `bun run build` first (expected dist/web/index.html).")}function qg(g){let[f,...$]=process.platform==="darwin"?["open",g]:process.platform==="win32"?["cmd","/c","start","",g]:["xdg-open",g];try{gg(f,$,{stdio:"ignore",detached:!0}).unref()}catch{}}function b(g){let f=Xg(),$=Yg(async(J,S)=>{let Y=decodeURIComponent(new URL(J.url??"/","http://localhost").pathname),A=Ag(p(f,Y==="/"?"/index.html":Y));if(!A.startsWith(f)){S.writeHead(403),S.end("Forbidden");return}try{let j=await Sg(A);if(!j.isFile())throw Error("not a file");S.writeHead(200,{"Content-Type":Gg[Vg(A)]??"application/octet-stream","Content-Length":j.size}),fg(A).pipe(S)}catch{S.writeHead(404),S.end("Not Found")}}),V=()=>{let{port:J}=$.address(),S=`http://localhost:${J}`;if(console.log(`cursor-usage dashboard running at ${S}`),console.log("Drop a Cursor usage-events CSV onto the page. Ctrl+C to stop."),g.open)qg(S)};if(g.port!==void 0){$.listen(g.port,V);return}$.once("error",(J)=>{if(J.code!=="EADDRINUSE")throw J;console.log(`port ${u} is in use, picked a free port instead`),$.listen(0)}),$.once("listening",V),$.listen(u)}var zg=process.stdout.isTTY&&!process.env.NO_COLOR,R=(g)=>(f)=>zg?`\x1B[${g}m${f}\x1B[0m`:f,Q=R("1"),z=R("2"),v=R("36"),Cg=R("32"),kg=R("33");function I(g){return`$${g.toFixed(2)}`}function U(g){if(g>=1e9)return`${(g/1e9).toFixed(1)}B`;if(g>=1e6)return`${(g/1e6).toFixed(1)}M`;if(g>=1000)return`${(g/1000).toFixed(1)}K`;return String(g)}function y(g,f,$){if(f<=0||g<=0)return"";let V=Math.round(g/f*$*8),J=Math.floor(V/8),S=V%8,Y=["","▏","▎","▍","▌","▋","▊","▉"];return"█".repeat(J)+(Y[S]??"")}function q(g,f){return g.length>=f?g:g+" ".repeat(f-g.length)}function C(g,f,$){return new Intl.DateTimeFormat("en-GB",{timeZone:f,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hourCycle:"h23"}).formatToParts(g).find((J)=>J.type===$)?.value??""}function Fg(g,f){return[C(g,f,"hour"),C(g,f,"minute"),C(g,f,"second")].join(":")}function Kg(g,f,$){let V=g.firstDay&&g.lastDay?`${g.firstDay} – ${g.lastDay}`:"no data",J=$?`${f}, user ${$}`:f,S=(A)=>z(q(A,14)),Y=(A)=>Q(q(A,12));return[`${Q("Cursor Usage")} ${V} ${z(`(${g.eventCount} events, ${g.dayCount} days, ${J})`)}`,"",` ${S("Total Cost")}${Y(I(g.totalCost))} ${S("Total Tokens")}${Y(U(g.totalTokens))}`,` ${S("Avg/Active")}${Y(I(g.avgCostPerActiveDay))} ${S("Max Mode")}${Y(`${Math.round(g.maxModeRatio*100)}%`)}`,` ${S("Users")}${Y(String(g.userCount))} ${S("Models")}${Y(String(g.modelCount))}`]}function W(g,f,$={totalCost:0}){let{totalCost:V,barWidth:J=28,maxRows:S=15}=$,Y=f.slice(0,S),A=Math.max(...Y.map((X)=>X.key.length),4),j=Math.max(...Y.map((X)=>X.cost),0),G=[Q(g)];for(let X of Y){let _=V>0?` ${z(`${Math.round(X.cost/V*100)}%`)}`:"";G.push(` ${q(X.key,A)} ${q(I(X.cost),8)} ${v(q(y(X.cost,j,J),J))}${_} ${z(`${U(X.totalTokens)} tok, ${X.eventCount} ev`)}`)}if(f.length>S)G.push(z(` … and ${f.length-S} more`));return G}function i(g,f,$,V){let J=K(g,$),S=[Kg(J,$,V)],Y={day:()=>W("Daily Cost",O(g,$),{totalCost:J.totalCost,maxRows:31}),model:()=>W("By Model",B(g),{totalCost:J.totalCost}),user:()=>W("By User",N(g),{totalCost:J.totalCost})};if(f)S.push(Y[f]());else S.push(Y.day(),Y.model(),Y.user());return S.map((A)=>A.join(`
2
+ import{readFile as yf}from"node:fs/promises";import{parseArgs as p}from"node:util";var u="Errored, No Charge";var F="UTC";function x(){return Intl.DateTimeFormat().resolvedOptions().timeZone||F}function h(f){try{return new Intl.DateTimeFormat("en-US",{timeZone:f}).format(new Date),!0}catch{return!1}}var l=new Map;function Af(f){let g=l.get(f);if(g)return g;let $=new Intl.DateTimeFormat("en-GB",{timeZone:f,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",hourCycle:"h23"});return l.set(f,$),$}function y(f,g){return new Map(Af(g).formatToParts(f).map(($)=>[$.type,$.value]))}function qf(f,g,$){return y(f,g).get($)??""}function E(f){return Number.isInteger(f)&&f>=0&&f<=23}function n(f){if(!E(f))throw Error(`Invalid Daily Window start hour: ${f}`)}function Gf(f,g){let $=y(f,g);return{dateKey:[$.get("year"),$.get("month"),$.get("day")].join("-"),hour:Number($.get("hour")??0)}}function Lf(f){let[g,$,Y]=f.split("-").map(Number);if(g===void 0||$===void 0||Y===void 0)throw Error(`Invalid Daily Window Key: ${f}`);return{year:g,month:$,date:Y}}function Xf(f){return f.toISOString().slice(0,10)}function Qf(f,g){let{year:$,month:Y,date:z}=Lf(f);return Xf(new Date(Date.UTC($,Y-1,z)+g*86400000))}function _(f,g=F,$=0){n($);let{dateKey:Y,hour:z}=Gf(f,g);return z<$?Qf(Y,-1):Y}function Ff(f,g=F){return qf(f,g,"hour")}function i(f=0){return n(f),Array.from({length:24},(g,$)=>String((f+$)%24).padStart(2,"0"))}function R(f,g,$=F,Y=0){return f.filter((z)=>_(z.date,$,Y)===g)}function m(f,g=F,$=0){let Y=[...f].sort((z,c)=>c.date.getTime()-z.date.getTime())[0];return Y?_(Y.date,g,$):null}function d(f){return f.filter((g)=>g.kind!==u)}function N(f,g=F,$=0){let Y=0,z=0,c=0,V=new Set,J=new Set,S=new Set;for(let q of f){if(Y+=q.cost,z+=q.totalTokens,q.maxMode)c++;V.add(_(q.date,g,$)),J.add(q.user),S.add(q.model)}let A=[...V].sort();return{totalCost:Y,totalTokens:z,eventCount:f.length,firstDailyWindow:A[0]??null,lastDailyWindow:A[A.length-1]??null,dailyWindowCount:V.size,avgCostPerActiveDailyWindow:V.size>0?Y/V.size:0,maxModeRatio:f.length>0?c/f.length:0,userCount:J.size,modelCount:S.size}}function K(f,g){let $=new Map;for(let Y of f){let z=g(Y),c=$.get(z);if(!c)c={key:z,cost:0,totalTokens:0,inputTokens:0,outputTokens:0,cacheRead:0,eventCount:0},$.set(z,c);c.cost+=Y.cost,c.totalTokens+=Y.totalTokens,c.inputTokens+=Y.inputWithCacheWrite+Y.inputWithoutCacheWrite,c.outputTokens+=Y.outputTokens,c.cacheRead+=Y.cacheRead,c.eventCount++}return[...$.values()]}function C(f,g=F,$=0){return K(f,(Y)=>_(Y.date,g,$)).sort((Y,z)=>Y.key.localeCompare(z.key))}function D(f){return K(f,(g)=>g.user).sort((g,$)=>$.cost-g.cost)}function O(f){return K(f,(g)=>g.model).sort((g,$)=>$.cost-g.cost)}function H(f){return K(f,(g)=>g.kind).sort((g,$)=>$.cost-g.cost)}function W(f,g=F){return K(f,($)=>Ff($.date,g)).sort(($,Y)=>$.key.localeCompare(Y.key))}function r(f,g){return[...f].sort(($,Y)=>Y.cost-$.cost).slice(0,g)}function jf(f){let g=[],$=[],Y="",z=!1,c=0,V=()=>{$.push(Y),Y=""},J=()=>{V(),g.push($),$=[]};while(c<f.length){let S=f[c];if(z){if(S==='"'){if(f[c+1]==='"'){Y+='"',c+=2;continue}z=!1,c++;continue}Y+=S,c++;continue}if(S==='"'){z=!0,c++;continue}if(S===","){V(),c++;continue}if(S==="\r"){c++;continue}if(S===`
3
+ `){J(),c++;continue}Y+=S,c++}if(Y.length>0||$.length>0)J();return g}var Nf=["Date","User","Cloud Agent ID","Automation ID","Kind","Model","Max Mode","Input (w/ Cache Write)","Input (w/o Cache Write)","Cache Read","Output Tokens","Total Tokens","Cost"];function P(f){if(!f)return 0;let g=Number(f);return Number.isFinite(g)?g:0}function a(f){let g=jf(f.trim());if(g.length===0)return[];let $=g[0],Y=new Map;$.forEach((V,J)=>Y.set(V.trim(),J));for(let V of["Date","User","Model","Cost"])if(!Y.has(V))throw Error(`Invalid CSV: missing column "${V}". Expected a Cursor usage-events export with columns: ${Nf.join(", ")}`);let z=(V,J)=>{let S=Y.get(J);return S===void 0?"":(V[S]??"").trim()},c=[];for(let V of g.slice(1)){if(V.length===1&&V[0]==="")continue;let J=z(V,"Date"),S=new Date(J);if(Number.isNaN(S.getTime()))continue;c.push({date:S,user:z(V,"User"),cloudAgentId:z(V,"Cloud Agent ID")||null,automationId:z(V,"Automation ID")||null,kind:z(V,"Kind"),model:z(V,"Model"),maxMode:z(V,"Max Mode").toLowerCase()==="yes",inputWithCacheWrite:P(z(V,"Input (w/ Cache Write)")),inputWithoutCacheWrite:P(z(V,"Input (w/o Cache Write)")),cacheRead:P(z(V,"Cache Read")),outputTokens:P(z(V,"Output Tokens")),totalTokens:P(z(V,"Total Tokens")),cost:P(z(V,"Cost"))})}return c}import{spawn as Pf}from"node:child_process";import{createReadStream as If,existsSync as Bf}from"node:fs";import{stat as Mf}from"node:fs/promises";import{createServer as Rf}from"node:http";import{dirname as Kf,extname as Df,join as k,normalize as Of}from"node:path";import{fileURLToPath as Uf}from"node:url";var s=4321,xf={".html":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".map":"application/json"};function _f(){let f=Kf(Uf(import.meta.url)),g=[k(f,"web"),k(f,"../../dist/web")];for(let $ of g)if(Bf(k($,"index.html")))return $;throw Error("Dashboard assets not found. Run `bun run build` first (expected dist/web/index.html).")}function Cf(f){let[g,...$]=process.platform==="darwin"?["open",f]:process.platform==="win32"?["cmd","/c","start","",f]:["xdg-open",f];try{Pf(g,$,{stdio:"ignore",detached:!0}).unref()}catch{}}function kf(f){return Rf(async(g,$)=>{let Y=decodeURIComponent(new URL(g.url??"/","http://localhost").pathname),z=Of(k(f,Y==="/"?"/index.html":Y));if(!z.startsWith(f)){$.writeHead(403),$.end("Forbidden");return}try{let c=await Mf(z);if(!c.isFile())throw Error("not a file");$.writeHead(200,{"Content-Type":xf[Df(z)]??"application/octet-stream","Content-Length":c.size}),If(z).pipe($)}catch{$.writeHead(404),$.end("Not Found")}})}function w(f={}){let g=_f(),$=kf(g);return new Promise((Y,z)=>{let c=()=>{let{port:V}=$.address();Y({server:$,url:`http://localhost:${V}`})};if(f.port!==void 0){$.once("error",z),$.listen(f.port,c);return}$.once("error",(V)=>{if(V.code!=="EADDRINUSE"){z(V);return}console.log(`port ${s} is in use, picked a free port instead`),$.listen(0)}),$.once("listening",c),$.listen(s)})}function o(f){w({port:f.port}).then(({url:g})=>{if(console.log(`cursor-usage dashboard running at ${g}`),console.log("Drop a Cursor usage-events CSV onto the page. Ctrl+C to stop."),f.open)Cf(g)}).catch((g)=>{throw g})}var Tf=process.stdout.isTTY&&!process.env.NO_COLOR,U=(f)=>(g)=>Tf?`\x1B[${f}m${g}\x1B[0m`:g,j=U("1"),Q=U("2"),t=U("36"),Sg=U("32"),Ag=U("33");function B(f){return`$${f.toFixed(2)}`}function T(f){if(f>=1e9)return`${(f/1e9).toFixed(1)}B`;if(f>=1e6)return`${(f/1e6).toFixed(1)}M`;if(f>=1000)return`${(f/1000).toFixed(1)}K`;return String(f)}function e(f,g,$){if(g<=0||f<=0)return"";let Y=Math.round(f/g*$*8),z=Math.floor(Y/8),c=Y%8,V=["","▏","▎","▍","▌","▋","▊","▉"];return"█".repeat(z)+(V[c]??"")}function X(f,g){return f.length>=g?f:f+" ".repeat(g-f.length)}function b(f,g,$){return new Intl.DateTimeFormat("en-GB",{timeZone:g,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hourCycle:"h23"}).formatToParts(f).find((z)=>z.type===$)?.value??""}function pf(f,g){return[b(f,g,"hour"),b(f,g,"minute"),b(f,g,"second")].join(":")}function hf(f,g,$,Y){let z=f.firstDailyWindow&&f.lastDailyWindow?`${f.firstDailyWindow} – ${f.lastDailyWindow}`:"no data",c=$?`${g}, start ${Y}:00, user ${$}`:`${g}, start ${Y}:00`,V=(S)=>Q(X(S,14)),J=(S)=>j(X(S,12));return[`${j("Cursor Usage")} ${z} ${Q(`(${f.eventCount} events, ${f.dailyWindowCount} daily windows, ${c})`)}`,"",` ${V("Total Cost")}${J(B(f.totalCost))} ${V("Total Tokens")}${J(T(f.totalTokens))}`,` ${V("Avg/Active")}${J(B(f.avgCostPerActiveDailyWindow))} ${V("Max Mode")}${J(`${Math.round(f.maxModeRatio*100)}%`)}`,` ${V("Users")}${J(String(f.userCount))} ${V("Models")}${J(String(f.modelCount))}`]}function I(f,g,$={totalCost:0}){let{totalCost:Y,barWidth:z=28,maxRows:c=15}=$,V=g.slice(0,c),J=Math.max(...V.map((q)=>q.key.length),4),S=Math.max(...V.map((q)=>q.cost),0),A=[j(f)];for(let q of V){let G=Y>0?` ${Q(`${Math.round(q.cost/Y*100)}%`)}`:"";A.push(` ${X(q.key,J)} ${X(B(q.cost),8)} ${t(X(e(q.cost,S,z),z))}${G} ${Q(`${T(q.totalTokens)} tok, ${q.eventCount} ev`)}`)}if(g.length>c)A.push(Q(` … and ${g.length-c} more`));return A}function ff(f,g,$,Y,z=0){let c=N(f,$,z),V=[hf(c,$,Y,z)],J={"daily-window":()=>I("Daily Window Cost",C(f,$,z),{totalCost:c.totalCost,maxRows:31}),model:()=>I("By Model",O(f),{totalCost:c.totalCost}),user:()=>I("By User",D(f),{totalCost:c.totalCost})};if(g)V.push(J[g]());else V.push(J["daily-window"](),J.model(),J.user());return V.map((S)=>S.join(`
4
4
  `)).join(`
5
5
 
6
6
  `)+`
7
- `}function r(g,f,$){return JSON.stringify({timeZone:f,filters:{user:$??null},summary:K(g,f),byDay:O(g,f),byModel:B(g),byUser:N(g)},null,2)}function Qg(g,f,$,V,J,S){let Y=K(f,$),A=V>0?Math.round(Y.totalCost/V*100):0,j=(X)=>z(q(X,14)),G=(X)=>Q(q(X,12));return[`${Q(`Day ${g}`)} ${z(`(${Y.eventCount} events, rank ${J}/${S} by cost, ${$})`)}`,"",` ${j("Cost")}${G(I(Y.totalCost))} ${j("of period")}${G(`${A}%`)}`,` ${j("Total Tokens")}${G(U(Y.totalTokens))} ${j("Max Mode")}${G(`${Math.round(Y.maxModeRatio*100)}%`)}`,` ${j("Users")}${G(String(Y.userCount))} ${j("Models")}${G(String(Y.modelCount))}`]}function Lg(g,f){let $=new Map(T(g,f).map((S)=>[S.key,S])),V=Math.max(...[...$.values()].map((S)=>S.cost),0),J=[Q(`By Hour (${f})`)];for(let S=0;S<24;S++){let Y=String(S).padStart(2,"0"),A=$.get(Y),j=A?.cost??0,G=A?.eventCount??0,X=G>0?z(` ${G} ev`):"";J.push(` ${Y} ${q(j>0?I(j):"",8)} ${v(q(y(j,V,24),24))}${X}`)}return J}function Hg(g,f,$){let V=l(g,f),J=[Q(`Top Events (${V.length} of ${g.length})`)],S=Math.max(...V.map((A)=>A.user.length),4),Y=Math.max(...V.map((A)=>A.model.length),5);for(let A of V){let j=Fg(A.date,$);J.push(` ${z(j)} ${q(A.user,S)} ${q(A.model,Y)} ${q(I(A.cost),8)} ${z(`${U(A.totalTokens)} tok`)}`)}return J}function d(g,f,$,V){let J=O(g,$),S=D(g,f,$);if(S.length===0){let G=J.map((_)=>_.key),X=G.length>0?`
8
- Available days: ${G[0]} – ${G[G.length-1]}`:"";return`No billable events on ${f}.${X}
9
- `}let Y=g.reduce((G,X)=>G+X.cost,0),A=[...J].sort((G,X)=>X.cost-G.cost).findIndex((G)=>G.key===f)+1;return[Qg(f,S,$,Y,A,J.length),...V?[[z(`Filtered to user: ${V}`)]]:[],Lg(S,$),W("By Model",B(S),{totalCost:K(S,$).totalCost}),W("By User",N(S),{totalCost:K(S,$).totalCost}),W("By Kind",c(S),{totalCost:0}),Hg(S,20,$)].map((G)=>G.join(`
7
+ `}function gf(f,g,$,Y=0){return JSON.stringify({timeZone:g,startHour:Y,filters:{user:$??null},summary:N(f,g,Y),byDailyWindow:C(f,g,Y),byModel:O(f),byUser:D(f)},null,2)}function Ef(f,g,$,Y,z,c,V){let J=N(g,$,Y),S=z>0?Math.round(J.totalCost/z*100):0,A=(G)=>Q(X(G,14)),q=(G)=>j(X(G,12));return[`${j(`Daily Window ${f}`)} ${Q(`(${J.eventCount} events, rank ${c}/${V} by cost, ${$}, start ${Y}:00)`)}`,"",` ${A("Cost")}${q(B(J.totalCost))} ${A("of period")}${q(`${S}%`)}`,` ${A("Total Tokens")}${q(T(J.totalTokens))} ${A("Max Mode")}${q(`${Math.round(J.maxModeRatio*100)}%`)}`,` ${A("Users")}${q(String(J.userCount))} ${A("Models")}${q(String(J.modelCount))}`]}function Hf(f,g,$){let Y=new Map(W(f,g).map((V)=>[V.key,V])),z=Math.max(...[...Y.values()].map((V)=>V.cost),0),c=[j(`By Hour (${g})`)];for(let V of i($)){let J=Y.get(V),S=J?.cost??0,A=J?.eventCount??0,q=A>0?Q(` ${A} ev`):"";c.push(` ${V} ${X(S>0?B(S):"",8)} ${t(X(e(S,z,24),24))}${q}`)}return c}function Wf(f,g,$){let Y=r(f,g),z=[j(`Top Events (${Y.length} of ${f.length})`)],c=Math.max(...Y.map((J)=>J.user.length),4),V=Math.max(...Y.map((J)=>J.model.length),5);for(let J of Y){let S=pf(J.date,$);z.push(` ${Q(S)} ${X(J.user,c)} ${X(J.model,V)} ${X(B(J.cost),8)} ${Q(`${T(J.totalTokens)} tok`)}`)}return z}function $f(f,g,$,Y,z=0){let c=C(f,$,z),V=R(f,g,$,z);if(V.length===0){let G=c.map((Sf)=>Sf.key),M=G.length>0?`
8
+ Available Daily Windows: ${G[0]} – ${G[G.length-1]}`:"";return`No billable events in Daily Window ${g}.${M}
9
+ `}let J=f.reduce((G,M)=>G+M.cost,0),S=[...c].sort((G,M)=>M.cost-G.cost).findIndex((G)=>G.key===g)+1,A=N(V,$,z).totalCost;return[Ef(g,V,$,z,J,S,c.length),...Y?[[Q(`Filtered to user: ${Y}`)]]:[],Hf(V,$,z),I("By Model",O(V),{totalCost:A}),I("By User",D(V),{totalCost:A}),I("By Kind",H(V),{totalCost:A}),Wf(V,20,$)].map((G)=>G.join(`
10
10
  `)).join(`
11
11
 
12
12
  `)+`
13
- `}function o(g,f,$,V){let J=D(g,f,$);return JSON.stringify({day:f,timeZone:$,filters:{user:V??null},summary:K(J,$),byHour:T(J,$),byModel:B(J),byUser:N(J),byKind:c(J)},null,2)}var s=`cursor-usage visualize Cursor usage-events CSV
13
+ `}function Yf(f,g,$,Y,z=0){let c=R(f,g,$,z);return JSON.stringify({dailyWindow:g,timeZone:$,startHour:z,filters:{user:Y??null},summary:N(c,$,z),byHour:W(c,$),byModel:O(c),byUser:D(c),byKind:H(c)},null,2)}import{basename as wf,dirname as bf,extname as Zf,join as Vf}from"node:path";function vf(f){let{csvPath:g,dailyWindow:$,dailyReport:Y,out:z}=f;if(z)return z;if(Y)return Vf(process.cwd(),"daily-report.png");let c=Zf(g),V=wf(g,c);return Vf(bf(g),`${V}${$?`-${$}-daily`:""}.png`)}async function uf(){let f=Function("specifier","return import(specifier)");try{let g=await f("playwright-core");if(!g.chromium)throw Error("playwright-core did not expose chromium");return g.chromium}catch(g){throw Error(`Screenshot export requires playwright-core. Install dependencies and try again.
14
+ ${String(g)}`)}}function lf(f){return f.map((g)=>({...g,date:g.date.toISOString()}))}async function Z(f){if(f.events.length===0)throw Error("No billable usage events found in the Usage Export.");if(f.dailyWindow){if(R(f.events,f.dailyWindow,f.timeZone,f.startHour).length===0)throw Error(`No billable usage events found in Daily Window ${f.dailyWindow} (start hour ${f.startHour}, ${f.timeZone}).`)}let g=await uf(),$=process.env.CHROME_PATH!==void 0?{headless:!0,executablePath:process.env.CHROME_PATH}:{headless:!0,channel:process.env.PLAYWRIGHT_CHROME_CHANNEL??"chrome"},Y;try{Y=await g.launch($)}catch(V){throw Error(["Could not launch Chrome for screenshot export.","Install Google Chrome, set CHROME_PATH, or set PLAYWRIGHT_CHROME_CHANNEL to an installed Chromium channel.",String(V)].join(`
15
+ `))}let z=vf(f),c=await w({port:0});try{let V=await Y.newPage({viewport:{width:1200,height:900}});await V.addInitScript({content:`window.__CURSOR_USAGE_EVENTS__ = ${JSON.stringify(lf(f.events))};`});let J=new URL(c.url);J.hash=new URLSearchParams({timezone:f.timeZone,...f.user?{user:f.user}:{},...f.dailyWindow?{"daily-window":f.dailyWindow}:{},...f.startHour!==0?{"start-hour":String(f.startHour)}:{},...f.eventLimit!==void 0?{"event-limit":String(f.eventLimit)}:{}}).toString(),await V.goto(J.href,{waitUntil:"networkidle"}),await V.waitForSelector(".grid",{timeout:1e4}),await V.screenshot({path:z,fullPage:!0})}finally{c.server.close(),await Y.close()}return z}var cf=`cursor-usage — visualize Cursor usage-events CSV
14
16
 
15
17
  Usage:
16
18
  cursor-usage [serve] [--port <n>] [--no-open] Start the drag & drop dashboard (default)
17
19
  cursor-usage stats <csv> [options] Show usage statistics in the terminal
20
+ cursor-usage screenshot <csv> [options] Capture the dashboard as a PNG
21
+ cursor-usage daily-report <csv> Capture a shareable daily report PNG
18
22
 
19
23
  Stats options:
20
- --by <day|user|model> Show a single breakdown axis (default: all)
21
- --day <YYYY-MM-DD> Drill into a single day (hourly, model, user, kind, top events)
22
- --user <identifier> Filter analysis to a single User
23
- --timezone <iana-tz> Analysis time zone (default: current environment)
24
- --json Output aggregated stats as JSON
25
- --include-no-charge Include "Errored, No Charge" events
24
+ --by <daily-window|user|model> Show a single breakdown axis (default: all)
25
+ --daily-window <YYYY-MM-DD> Drill into a single Daily Window
26
+ --start-hour <0-23> Daily Window start hour (default: 0)
27
+ --user <identifier> Filter analysis to a single User
28
+ --timezone <iana-tz> Analysis time zone (default: current environment)
29
+ --json Output aggregated stats as JSON
30
+ --include-no-charge Include "Errored, No Charge" events
31
+
32
+ Screenshot options:
33
+ --daily-window <YYYY-MM-DD> Capture a Daily Window detail view
34
+ --start-hour <0-23> Daily Window start hour (default: 0)
35
+ --event-limit <n> Limit Daily Window event table rows
36
+ --out <path> Output path
37
+ --user <identifier> Filter screenshot to a single User
38
+ --timezone <iana-tz> Analysis time zone (default: current environment)
39
+ --include-no-charge Include "Errored, No Charge" events
26
40
 
27
41
  Serve options:
28
42
  --port <n> Fixed port to listen on
@@ -30,5 +44,5 @@ Serve options:
30
44
  --no-open Do not open the browser automatically
31
45
 
32
46
  -h, --help Show this help
33
- `;function L(g){console.error(`Error: ${g}
34
- `),console.error(s),process.exit(1)}async function Ig(g){let{values:f,positionals:$}=m({args:g,allowPositionals:!0,options:{by:{type:"string"},day:{type:"string"},user:{type:"string"},timezone:{type:"string"},json:{type:"boolean",default:!1},"include-no-charge":{type:"boolean",default:!1}}}),V=$[0];if(!V)L("stats requires a path to a CSV file");let J;try{J=await Wg(V,"utf8")}catch{L(`file not found: ${V}`)}let S=f.by;if(S&&!["day","user","model"].includes(S))L(`invalid --by value: ${S} (expected day, user or model)`);let Y=f.day;if(Y!==void 0&&!/^\d{4}-\d{2}-\d{2}$/.test(Y))L(`invalid --day value: ${Y} (expected YYYY-MM-DD)`);let A=f.timezone??w();if(!h(A))L(`invalid --timezone value: ${A}`);let j=Z(J);if(!f["include-no-charge"])j=E(j);let G=f.user;if(G)j=j.filter((X)=>X.user===G);if(j.length===0)console.error("No usage events found for the requested filters."),process.exit(1);if(Y){console.log(f.json?o(j,Y,A,G):d(j,Y,A,G));return}console.log(f.json?r(j,A,G):i(j,S,A,G))}function n(g){let{values:f}=m({args:g,allowPositionals:!1,options:{port:{type:"string"},"no-open":{type:"boolean",default:!1}}}),$;if(f.port!==void 0){if($=Number(f.port),!Number.isInteger($)||$<0||$>65535)L(`invalid --port value: ${f.port}`)}b({port:$,open:!f["no-open"]})}async function Pg(){let g=process.argv.slice(2);if(g.includes("-h")||g.includes("--help")){console.log(s);return}let[f,...$]=g;switch(f){case"stats":await Ig($);break;case"serve":n($);break;case void 0:n([]);break;default:L(`unknown command: ${f}`)}}await Pg();
47
+ `;function L(f){console.error(`Error: ${f}
48
+ `),console.error(cf),process.exit(1)}function Jf(f,g){if(f===void 0)return g;let $=Number(f);if(!E($))L(`invalid --start-hour value: ${f} (expected 0-23)`);return $}function nf(f){if(f===void 0)return;let g=Number(f);if(!Number.isInteger(g)||g<=0)L(`invalid --event-limit value: ${f} (expected a positive integer)`);return g}async function v(f,g,$,Y="No usage events found for the requested filters."){let z;try{z=await yf(f,"utf8")}catch{L(`file not found: ${f}`)}let c=a(z);if(!g)c=d(c);if($)c=c.filter((V)=>V.user===$);if(c.length===0)L(Y);return c}async function mf(f){let{values:g,positionals:$}=p({args:f,allowPositionals:!0,options:{by:{type:"string"},"daily-window":{type:"string"},"start-hour":{type:"string"},user:{type:"string"},timezone:{type:"string"},json:{type:"boolean",default:!1},"include-no-charge":{type:"boolean",default:!1}}}),Y=$[0];if(!Y)L("stats requires a path to a CSV file");let z=g.by;if(z&&!["daily-window","user","model"].includes(z))L(`invalid --by value: ${z} (expected daily-window, user or model)`);let c=g["daily-window"];if(c!==void 0&&!/^\d{4}-\d{2}-\d{2}$/.test(c))L(`invalid --daily-window value: ${c} (expected YYYY-MM-DD)`);let V=g.timezone??x();if(!h(V))L(`invalid --timezone value: ${V}`);let J=Jf(g["start-hour"],0),S=g.user,A=await v(Y,g["include-no-charge"],S);if(c){console.log(g.json?Yf(A,c,V,S,J):$f(A,c,V,S,J));return}console.log(g.json?gf(A,V,S,J):ff(A,z,V,S,J))}async function df(f){let{values:g,positionals:$}=p({args:f,allowPositionals:!0,options:{"daily-window":{type:"string"},"start-hour":{type:"string"},"event-limit":{type:"string"},out:{type:"string"},user:{type:"string"},timezone:{type:"string"},"include-no-charge":{type:"boolean",default:!1}}}),Y=$[0];if(!Y)L("screenshot requires a path to a CSV file");let z=g.timezone??x();if(!h(z))L(`invalid --timezone value: ${z}`);let c=g["daily-window"];if(c!==void 0&&!/^\d{4}-\d{2}-\d{2}$/.test(c))L(`invalid --daily-window value: ${c} (expected YYYY-MM-DD)`);let V=Jf(g["start-hour"],0),J=nf(g["event-limit"]),S=await v(Y,g["include-no-charge"],g.user),A=await Z({csvPath:Y,events:S,timeZone:z,dailyWindow:c,startHour:V,eventLimit:J,dailyReport:!1,out:g.out,user:g.user});console.log(`wrote ${A}`)}async function rf(f){let{positionals:g}=p({args:f,allowPositionals:!0,options:{}}),$=g[0];if(!$)L("daily-report requires a path to a CSV file");if(g.length>1)L(`unexpected argument: ${g[1]}`);let Y=x(),z=5,c=await v($,!1,void 0,"No billable usage events found in the Usage Export."),V=m(c,Y,z);if(!V)L("No billable usage events found in the Usage Export.");let J=await Z({csvPath:$,events:c,timeZone:Y,dailyWindow:V,startHour:z,eventLimit:10,dailyReport:!0});console.log(`wrote ${J}`)}function zf(f){let{values:g}=p({args:f,allowPositionals:!1,options:{port:{type:"string"},"no-open":{type:"boolean",default:!1}}}),$;if(g.port!==void 0){if($=Number(g.port),!Number.isInteger($)||$<0||$>65535)L(`invalid --port value: ${g.port}`)}o({port:$,open:!g["no-open"]})}async function af(){let f=process.argv.slice(2);if(f.includes("-h")||f.includes("--help")){console.log(cf);return}let[g,...$]=f;switch(g){case"stats":await mf($);break;case"screenshot":await df($);break;case"daily-report":await rf($);break;case"serve":zf($);break;case void 0:zf([]);break;default:L(`unknown command: ${g}`)}}await af();