claude-usage-explorer 1.0.1 → 1.1.6

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.
@@ -1,30 +1,30 @@
1
- {
2
- "name": "claude-usage-explorer-marketplace",
3
- "description": "Local, read-only Claude Code token usage analytics and terminal tools",
4
- "owner": {
5
- "name": "IliasPol",
6
- "url": "https://github.com/IliasPol"
7
- },
8
- "plugins": [
9
- {
10
- "name": "claude-usage",
11
- "source": "./",
12
- "description": "Explore Claude Code projects, sessions, token usage, model breakdowns and estimated costs locally",
13
- "version": "1.0.0",
14
- "author": {
15
- "name": "IliasPol"
16
- },
17
- "homepage": "https://github.com/IliasPol/claude-usage",
18
- "repository": "https://github.com/IliasPol/claude-usage",
19
- "license": "MIT",
20
- "category": "development",
21
- "tags": [
22
- "claude-code",
23
- "usage",
24
- "analytics",
25
- "tokens",
26
- "terminal"
27
- ]
28
- }
29
- ]
30
- }
1
+ {
2
+ "name": "claude-usage-explorer-marketplace",
3
+ "description": "Local, read-only Claude Code token usage analytics and terminal tools",
4
+ "owner": {
5
+ "name": "IliasPol",
6
+ "url": "https://github.com/IliasPol"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "claude-usage",
11
+ "source": "./",
12
+ "description": "Explore Claude Code projects, sessions, token usage, model breakdowns and estimated costs locally",
13
+ "version": "1.1.6",
14
+ "author": {
15
+ "name": "IliasPol"
16
+ },
17
+ "homepage": "https://github.com/IliasPol/claude-usage",
18
+ "repository": "https://github.com/IliasPol/claude-usage",
19
+ "license": "MIT",
20
+ "category": "development",
21
+ "tags": [
22
+ "claude-code",
23
+ "usage",
24
+ "analytics",
25
+ "tokens",
26
+ "terminal"
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -1,11 +1,11 @@
1
- {
2
- "name": "claude-usage",
3
- "description": "Local, read-only Claude Code usage analytics with a native sidebar and standalone terminal UI",
4
- "version": "1.0.0",
5
- "author": {
6
- "name": "IliasPol"
7
- },
8
- "homepage": "https://github.com/IliasPol/claude-usage",
9
- "repository": "https://github.com/IliasPol/claude-usage",
10
- "license": "MIT"
11
- }
1
+ {
2
+ "name": "claude-usage",
3
+ "description": "Local, read-only Claude Code usage analytics with a native sidebar and standalone terminal UI",
4
+ "version": "1.1.6",
5
+ "author": {
6
+ "name": "IliasPol"
7
+ },
8
+ "homepage": "https://github.com/IliasPol/claude-usage",
9
+ "repository": "https://github.com/IliasPol/claude-usage",
10
+ "license": "MIT"
11
+ }
package/README.md CHANGED
@@ -1,116 +1,127 @@
1
- # Claude Usage Explorer
2
-
3
- **Explore Claude Code token usage, estimated API-equivalent costs, projects, sessions, and agents — locally.** Use a native Claude Code sidebar or a standalone terminal dashboard. No uploads, telemetry, or external npm dependencies.
4
-
5
- [![npm version](https://img.shields.io/npm/v/claude-usage-explorer.svg)](https://www.npmjs.com/package/claude-usage-explorer)
6
- [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
-
8
- ## Preview
9
-
10
- ### Native Claude Code sidebar
11
-
12
- ![Claude Usage Explorer inside the Claude Code sidebar](docs/screenshots/native-sidebar.png)
13
-
14
- Use `/usage-explorer` to inspect local usage without leaving your Claude Code session.
15
-
16
- ### Standalone terminal dashboard
17
-
18
- ![Claude Usage Explorer standalone terminal dashboard](docs/screenshots/standalone-tui.png)
19
-
20
- Run `claude-usage tui` to explore projects, sessions, token usage, model breakdowns, and estimated costs.
21
-
22
- ## Quick start
23
-
24
- **Standalone CLI (Node.js 20+):**
25
-
26
- ```sh
27
- npm install -g claude-usage-explorer
28
- claude-usage tui
29
- ```
30
-
31
- **Claude Code community plugin marketplace:**
32
-
33
- ```sh
34
- claude plugin marketplace add IliasPol/claude-usage
35
- claude plugin install claude-usage@claude-usage-explorer-marketplace
36
- ```
37
-
38
- Then run `/usage-explorer` in a compatible Claude Code session.
39
-
40
- ## Requirements
41
-
42
- - Node.js 20+ (Node 22 recommended)
43
- - Claude Code with native Mods support for the sidebar
44
- - Local Claude Code JSONL transcript history
45
-
46
- ## Standalone CLI: other ways to run
47
-
48
- Run without a global installation:
49
-
50
- ```sh
51
- npx --yes --package claude-usage-explorer claude-usage tui
52
- ```
53
-
54
- **Note:** On Windows PowerShell, use `npm.cmd` and `claude-usage.cmd` if execution policy blocks the `.ps1` shims.
55
-
56
- To run directly from a local checkout, use:
57
-
58
- ```sh
59
- node ./bin/claude-usage.mjs tui
60
- ```
61
-
62
- Run the TUI in an **interactive terminal**, not inside a noninteractive Claude tool shell. The optional macOS/Linux installer `./scripts/install-command.sh` is still available for source checkouts but is not needed for npm installations.
63
-
64
- ## Claude Code plugin installation
65
-
66
- Alternatively, from inside Claude Code use `/plugin marketplace add IliasPol/claude-usage`, followed by `/plugin install claude-usage@claude-usage-explorer-marketplace`.
67
-
68
- This repository hosts a **community marketplace**, not an Anthropic-curated listing. After installation, use `/usage-explorer` in compatible Claude Code environments. Native mod/sidebar support may depend on the installed Claude Code version; if it is unavailable, the standalone `claude-usage tui` remains an option.
69
-
70
- ## Native sidebar: local development
71
-
72
- Start Claude Code with the local plugin directory:
73
-
74
- ```sh
75
- claude --plugin-dir /absolute/path/to/claude-usage-explorer
76
- ```
77
-
78
- Then use `/usage-explorer`. In Claude Code CLI, `/tui fullscreen` may be required for the sidebar to appear. The native command is **`/usage-explorer`**, not `/claude-usage:open`.
79
-
80
- The optional `/claude-usage:tui` skill attempts to open a **separate** terminal. This requires one model invocation and depends on your terminal environment. If no terminal appears, launch `node ./bin/claude-usage.mjs tui` directly in PowerShell or Terminal.
81
-
82
- ## Features
83
-
84
- - Drill down into projects, sessions, agents and models
85
- - Periods, sorting, filtering and auto-refresh
86
- - Token totals and estimated API-equivalent cost by model
87
- - Optional cached 5-hour and 7-day Claude Desktop plan-limit percentages (not live usage)
88
- - Read-only, local processing; no uploads or external analytics
89
-
90
- **Important:** Estimated API-equivalent cost is not what your Claude subscription was charged. Cached plan percentages can be stale. Claude Desktop Chat history is not included in Claude Code's local transcript analytics.
91
-
92
- ## Windows
93
-
94
- In PowerShell, from the extracted project directory (or use the npm installation above):
95
-
96
- ```powershell
97
- node .\bin\claude-usage.mjs tui
98
- claude --plugin-dir "$PWD"
99
- ```
100
-
101
- ## Development
102
-
103
- ```sh
104
- npm test
105
- npm run check
106
- ```
107
-
108
- The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
109
-
110
- ## Contributing, security, and license
111
-
112
- Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report vulnerabilities following [SECURITY.md](SECURITY.md). This project is licensed under the [MIT License](LICENSE).
113
-
114
- ## Support
115
-
116
- If Claude Usage Explorer is useful, consider [starring the repository](https://github.com/IliasPol/claude-usage). The tool is free and open source.
1
+ # Claude Usage Explorer
2
+
3
+ **Explore Claude Code token usage, estimated API-equivalent costs, projects, sessions, and agents — locally.** Use a native Claude Code sidebar or a standalone terminal dashboard. No uploads, telemetry, or external npm dependencies.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/claude-usage-explorer.svg)](https://www.npmjs.com/package/claude-usage-explorer)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ ## What's new in v1.1.6
9
+
10
+ - **Session actions:** open or copy session identifiers from supported views.
11
+ - **Account usage diagnostics:** optional official usage-limit and extra-spend information when accessible; not the same as estimated API-equivalent cost.
12
+ - **Cleaner agent navigation:** the main agent appears as **Agent (main)**; subagents use a recorded name when available, or **Subagent** when metadata is missing. Opaque agent IDs are shown only after opening an agent.
13
+ - **Native sidebar and standalone terminal explorer:** navigate projects, sessions, agents, and models with token and API-equivalent cost breakdowns.
14
+ - **Live refresh and flexible views:** filter and sort usage, switch reporting periods, and see per-model summaries.
15
+ - **Privacy-first:** transcript processing stays on your computer. Account plan-limit information, when available, is separate from local estimated API-equivalent cost.
16
+
17
+ **Important:** Subagent names depend on metadata in the local Claude transcript; names are not inferred when absent. Estimated API costs are not the amount billed for a Claude subscription.
18
+
19
+ ## Preview
20
+
21
+ ### Native Claude Code sidebar
22
+
23
+ ![Claude Usage Explorer inside the Claude Code sidebar](docs/screenshots/native-sidebar.png)
24
+
25
+ Use `/usage-explorer` to inspect local usage without leaving your Claude Code session.
26
+
27
+ ### Standalone terminal dashboard
28
+
29
+ ![Claude Usage Explorer standalone terminal dashboard](docs/screenshots/standalone-tui.png)
30
+
31
+ Run `claude-usage tui` to explore projects, sessions, token usage, model breakdowns, and estimated costs.
32
+
33
+ ## Quick start
34
+
35
+ **Standalone CLI (Node.js 20+):**
36
+
37
+ ```sh
38
+ npm install -g claude-usage-explorer
39
+ claude-usage tui
40
+ ```
41
+
42
+ **Claude Code community plugin marketplace:**
43
+
44
+ ```sh
45
+ claude plugin marketplace add IliasPol/claude-usage
46
+ claude plugin install claude-usage@claude-usage-explorer-marketplace
47
+ ```
48
+
49
+ Then run `/usage-explorer` in a compatible Claude Code session.
50
+
51
+ ## Requirements
52
+
53
+ - Node.js 20+ (Node 22 recommended)
54
+ - Claude Code with native Mods support for the sidebar
55
+ - Local Claude Code JSONL transcript history
56
+
57
+ ## Standalone CLI: other ways to run
58
+
59
+ Run without a global installation:
60
+
61
+ ```sh
62
+ npx --yes --package claude-usage-explorer claude-usage tui
63
+ ```
64
+
65
+ **Note:** On Windows PowerShell, use `npm.cmd` and `claude-usage.cmd` if execution policy blocks the `.ps1` shims.
66
+
67
+ To run directly from a local checkout, use:
68
+
69
+ ```sh
70
+ node ./bin/claude-usage.mjs tui
71
+ ```
72
+
73
+ Run the TUI in an **interactive terminal**, not inside a noninteractive Claude tool shell. The optional macOS/Linux installer `./scripts/install-command.sh` is still available for source checkouts but is not needed for npm installations.
74
+
75
+ ## Claude Code plugin installation
76
+
77
+ Alternatively, from inside Claude Code use `/plugin marketplace add IliasPol/claude-usage`, followed by `/plugin install claude-usage@claude-usage-explorer-marketplace`.
78
+
79
+ This repository hosts a **community marketplace**, not an Anthropic-curated listing. After installation, use `/usage-explorer` in compatible Claude Code environments. Native mod/sidebar support may depend on the installed Claude Code version; if it is unavailable, the standalone `claude-usage tui` remains an option.
80
+
81
+ ## Native sidebar: local development
82
+
83
+ Start Claude Code with the local plugin directory:
84
+
85
+ ```sh
86
+ claude --plugin-dir /absolute/path/to/claude-usage-explorer
87
+ ```
88
+
89
+ Then use `/usage-explorer`. In Claude Code CLI, `/tui fullscreen` may be required for the sidebar to appear. The native command is **`/usage-explorer`**, not `/claude-usage:open`.
90
+
91
+ The optional `/claude-usage:tui` skill attempts to open a **separate** terminal. This requires one model invocation and depends on your terminal environment. If no terminal appears, launch `node ./bin/claude-usage.mjs tui` directly in PowerShell or Terminal.
92
+
93
+ ## Features
94
+
95
+ - Drill down into projects, sessions, agents and models
96
+ - Periods, sorting, filtering and auto-refresh
97
+ - Token totals and estimated API-equivalent cost by model
98
+ - Optional cached 5-hour and 7-day Claude Desktop plan-limit percentages (not live usage)
99
+ - Read-only, local processing; no uploads or external analytics
100
+
101
+ **Important:** Estimated API-equivalent cost is not what your Claude subscription was charged. Cached plan percentages can be stale. Claude Desktop Chat history is not included in Claude Code's local transcript analytics.
102
+
103
+ ## Windows
104
+
105
+ In PowerShell, from the extracted project directory (or use the npm installation above):
106
+
107
+ ```powershell
108
+ node .\bin\claude-usage.mjs tui
109
+ claude --plugin-dir "$PWD"
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```sh
115
+ npm test
116
+ npm run check
117
+ ```
118
+
119
+ The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
120
+
121
+ ## Contributing, security, and license
122
+
123
+ Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report vulnerabilities following [SECURITY.md](SECURITY.md). This project is licensed under the [MIT License](LICENSE).
124
+
125
+ ## Support
126
+
127
+ If Claude Usage Explorer is useful, consider [starring the repository](https://github.com/IliasPol/claude-usage). The tool is free and open source.
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
2
5
  import { main } from '../src/core.mjs';
3
6
  import { launchTui } from '../src/tui.mjs';
4
7
  import { snapshot } from '../src/snapshot.mjs';
5
8
  import {runStatusLine,installStatusLine} from '../src/statusline.mjs';
6
9
  import {launchSidecar} from '../src/launcher.mjs';
7
- (process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
10
+ (process.argv[2] === 'preference' ? Promise.resolve().then(()=>{const file=path.join(os.homedir(),'.claude-usage-explorer','settings.json');let obj={};try{obj=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}if(process.argv[3]==='period' && ['all','cycle','previous-cycle','today','yesterday','7d','30d','month','previous-month'].includes(process.argv[4])){obj.lastPeriod=process.argv[4];fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});fs.writeFileSync(file,JSON.stringify(obj,null,2)+'\n',{mode:0o600});}else throw Error('Invalid preference');}) : process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
@@ -1,23 +1,32 @@
1
1
  // Claude Code native mod. The UI uses host components; local analytics run in a child Node process.
2
2
  // No model requests are made when opening, navigating or refreshing this pane.
3
- // Native hooks run in a restricted loader: no node: built-in imports.
4
- // Convert the file URL using web-standard URL APIs, preserving Windows drive paths.
3
+ // Native hooks run in a restricted loader; use web-standard URL APIs instead of node: imports.
5
4
  export function scriptPathFromModuleUrl(moduleUrl){
6
5
  const url=new URL('../bin/claude-usage.mjs',moduleUrl);
7
- let path=decodeURIComponent(url.pathname);
8
- if(/^\/[A-Za-z]:\//.test(path))path=path.slice(1);
9
- if(url.host&&url.host!=='localhost')path=`//${url.host}${path}`;
10
- return path;
6
+ let p=decodeURIComponent(url.pathname);
7
+ if(/^\/[A-Za-z]:\//.test(p))p=p.slice(1);
8
+ if(url.host&&url.host!=='localhost')p=`//${url.host}${p}`;
9
+ return p;
11
10
  }
12
11
  const PANE='claude-usage-explorer';
13
12
  const PERIODS=[['Billing cycle','cycle'],['Previous cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['7 days','7d'],['30 days','30d'],['This month','month'],['Previous month','previous-month']];
14
- let state={period:'all',view:'projects',stack:[],project:null,session:null,agent:null,report:null,periodLabel:'All time',loading:false,error:'',updatedAt:0,sort:'tokens',query:'',periodMenu:false,planUsage:null};
13
+ let state={period:'all',view:'projects',stack:[],project:null,session:null,agent:null,report:null,periodLabel:'All time',loading:false,error:'',updatedAt:0,sort:'tokens',query:'',periodMenu:false};
14
+ let limitState={status:'not checked',source:'',rateLimits:[],details:[],checkedAt:0};
15
+ let limitInFlight=null;
16
+ let usageFieldSchema=[];
17
+
18
+ let officialSpend=null;
19
+ let sessionActionStatus='';
20
+ let focusedSessionId=null;
21
+ const LIMIT_REFRESH_MS=180000;
15
22
  let timer=null;
16
23
  let inFlight=null;
17
24
  const total=s=>(s?.input||0)+(s?.output||0)+(s?.cacheRead||0)+(s?.cacheWrite||0);
18
25
  const compact=n=>Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n||0);
26
+ const share=(part,whole)=>whole>0?(part>0&&100*part/whole<0.05?'<0.1%':`${(100*part/whole).toFixed(1)}%`):'—';
19
27
  const price=s=>s?.unknownCostRequests?'N/A':`$${(s?.knownCost||0).toFixed(2)}`;
20
28
  const name=p=>String(p||'').replace(/\\/g,'/').split('/').filter(Boolean).pop()||p;
29
+ export const agentLabel=a=>a?.id==='main'?'Agent (main)':(a?.name||a?.type||'Subagent');
21
30
  const change=($,patch)=>{state={...state,...patch};void $.ui.invalidate('ui.render');};
22
31
  export function itemsFor(s=state){
23
32
  const r=s.report;
@@ -25,7 +34,7 @@ export function itemsFor(s=state){
25
34
  let items=[];
26
35
  if(s.view==='projects')items=r.projects.map(x=>({id:x.name,label:name(x.name),stats:x.stats,source:x}));
27
36
  if(s.view==='sessions')items=r.sessions.filter(x=>!s.project||x.project===s.project).map(x=>({id:x.id,label:x.title,stats:x.stats,source:x}));
28
- if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:x.id==='main'?'Main agent':x.id,stats:x.stats,source:x}));}
37
+ if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:agentLabel(x),stats:x.stats,source:x}));}
29
38
  if(s.view==='models'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=modelScope(s).map(x=>({id:x.name,label:x.name,stats:x.stats,source:x}));}
30
39
  if(s.query)items=items.filter(x=>x.label.toLowerCase().includes(s.query.toLowerCase()));
31
40
  return items.sort((a,b)=>s.sort==='cost'?(b.stats.knownCost-a.stats.knownCost):total(b.stats)-total(a.stats));
@@ -50,6 +59,9 @@ export function back(s){
50
59
  const last=s.stack[s.stack.length-1];
51
60
  return {...s,...last,stack:s.stack.slice(0,-1),query:''};
52
61
  }
62
+ async function persistPeriod($,period){
63
+ try {const script=scriptPathFromModuleUrl(import.meta.url);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
64
+ }
53
65
  async function refresh($){
54
66
  if(inFlight)return inFlight;
55
67
  change($,{loading:true,error:''});
@@ -60,13 +72,140 @@ async function refresh($){
60
72
  const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
61
73
  if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
62
74
  const snap=JSON.parse(result.stdout);
75
+ if(!state.preferencesLoaded){state.period=snap.savedPeriod||state.period;state.preferencesLoaded=true;if(state.period!==snap.period){inFlight=null;return refresh($);}}
63
76
  // Refresh preserves the active drilldown; a missing session is handled by an empty list.
64
- change($,{report:snap.report,planUsage:snap.planUsage||null,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
77
+ change($,{report:snap.report,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
65
78
  }catch(err){change($,{loading:false,error:String(err?.message||err).slice(0,260)});}
66
79
  })().finally(()=>{inFlight=null});
67
80
  return inFlight;
68
81
  }
69
- async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);}
82
+ // Opt-in diagnostic: never store or display the raw response, credentials or identifiers.
83
+ // The first-party OAuth usage route is undocumented; a failure is not fatal.
84
+ export function summarizeLimits(payload){
85
+ if(!payload||typeof payload!=='object')return [];
86
+ const lines=[];
87
+ for(const [key,val] of Object.entries(payload)){
88
+ if(!/^(five_hour|seven_day|seven_day_[a-z_]+|extra_usage|monthly|billing|usage|rate_limits)$/i.test(key))continue;
89
+ if(val&&typeof val==='object'&&!Array.isArray(val)){
90
+ const fields=[];
91
+ for(const field of ['utilization','percentUsed','limit','used','remaining','resets_at','resetsAt','is_enabled']){
92
+ const v=val[field];
93
+ if(typeof v==='number'&&Number.isFinite(v))fields.push(`${field}: ${v}`);
94
+ else if(typeof v==='boolean')fields.push(`${field}: ${v}`);
95
+ else if((field==='resets_at'||field==='resetsAt')&&typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v))fields.push(`${field}: ${v.slice(0,25)}`);
96
+ }
97
+ if(fields.length)lines.push(`${key}: ${fields.join(' · ')}`);
98
+ }
99
+ }
100
+ return lines.slice(0,12);
101
+ }
102
+ // Diagnostic exposes only structural metadata, never values, identifiers or credentials.
103
+ export function usageSchema(payload){
104
+ if(!payload||typeof payload!=='object'||Array.isArray(payload))return [];
105
+ const rows=[];
106
+ const walk=(obj,prefix,depth)=>{
107
+ for(const [key,value] of Object.entries(obj)){
108
+ if(rows.length>=90)return;
109
+ // Only keys with conventional field-name syntax; avoid leaking arbitrary map keys.
110
+ if(!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(key))continue;
111
+ const path=prefix?`${prefix}.${key}`:key;
112
+ const type=value===null?'null':Array.isArray(value)?'array':typeof value;
113
+ rows.push(`${path}: ${type}`);
114
+ if(depth<2&&type==='object')walk(value,path,depth+1);
115
+ }
116
+ };
117
+ walk(payload,'',0);
118
+ return rows;
119
+ }
120
+ // Normalize money using currency minor units. Never add spend and extra_usage together.
121
+ export function parseOfficialSpend(payload){
122
+ const spend=payload?.spend;
123
+ const money=(obj)=>{
124
+ if(!obj||!Number.isFinite(obj.amount_minor)||!Number.isInteger(obj.exponent)||obj.exponent<0||obj.exponent>8||typeof obj.currency!=='string')return null;
125
+ return {amount:obj.amount_minor/10**obj.exponent,currency:obj.currency.toUpperCase()};
126
+ };
127
+ const used=money(spend?.used),limit=money(spend?.limit);
128
+ if(used&&limit&&used.currency===limit.currency&&limit.amount>0){
129
+ return {used:used.amount,limit:limit.amount,currency:used.currency,percent:Number.isFinite(spend?.percent)?spend.percent:100*used.amount/limit.amount,source:'spend'};
130
+ }
131
+ const extra=payload?.extra_usage;
132
+ if(extra&&Number.isFinite(extra.used_credits)&&Number.isFinite(extra.monthly_limit)&&extra.monthly_limit>0&&typeof extra.currency==='string'){
133
+ return {used:extra.used_credits,limit:extra.monthly_limit,currency:extra.currency.toUpperCase(),percent:Number.isFinite(extra.utilization)?extra.utilization:100*extra.used_credits/extra.monthly_limit,source:'extra_usage'};
134
+ }
135
+ return null;
136
+ }
137
+ const currencyAmount=(amount,currency)=>{try{return new Intl.NumberFormat('en-US',{style:'currency',currency}).format(amount);}catch{return `${amount.toFixed(2)} ${currency}`;}};
138
+ async function checkLimits($,{force=false}={}){
139
+ if(limitInFlight)return limitInFlight;
140
+ if(!force&&limitState.checkedAt&&Date.now()-limitState.checkedAt<LIMIT_REFRESH_MS)return;
141
+ limitState={...limitState,status:'checking'};void $.ui.invalidate('ui.render');
142
+ limitInFlight=(async()=>{try{
143
+ const usage=await $.session.usage();
144
+ const rateLimits=Array.isArray(usage?.rateLimits)?usage.rateLimits:[];
145
+ limitState={status:rateLimits.length?'available':'no rate limits returned',source:'session.usage',rateLimits:rateLimits.map(x=>({kind:String(x.kind||'limit').slice(0,40),percentUsed:Number(x.percentUsed)||0,resetsAt:typeof x.resetsAt==='string'?x.resetsAt.slice(0,25):''})),details:[]};
146
+ const authorization=await $.session.authorize();
147
+ if(!authorization?.handle){limitState.status='No first-party authorization handle';return;}
148
+ const response=await $.http.fetch('https://api.anthropic.com/api/oauth/usage',{auth:authorization.handle});
149
+ if(!response.ok){limitState.status=`HTTP ${response.status}`;return;}
150
+ const payload=JSON.parse(response.text);
151
+ officialSpend=parseOfficialSpend(payload);
152
+ const details=summarizeLimits(payload);
153
+ limitState={...limitState,status:details.length?'available':'No recognized fields (Enterprise may differ)',source:'Claude session + OAuth usage',details};
154
+ }catch(err){limitState={...limitState,status:`Unavailable: ${String(err?.message||err).slice(0,100)}`};}
155
+ finally{limitState.checkedAt=Date.now();void $.ui.invalidate('ui.render');}
156
+ })().finally(()=>{limitInFlight=null});
157
+ return limitInFlight;
158
+ }
159
+ export function usageBar(percent,width=24){
160
+ const value=Math.max(0,Math.min(100,Number.isFinite(percent)?percent:0));
161
+ const count=Math.round(value/100*width);
162
+ return `${'█'.repeat(count)}${'░'.repeat(width-count)} ${value.toFixed(1)}%`;
163
+ }
164
+ export function officialLimitRows(limits){
165
+ const rows=[];
166
+ for(const limit of limits.rateLimits||[]){
167
+ if(Number.isFinite(limit.percentUsed))rows.push({label:limit.kind,percent:limit.percentUsed,resetsAt:limit.resetsAt||''});
168
+ }
169
+ for(const line of limits.details||[]){
170
+ const match=/^([^:]+):.*?utilization: ([0-9]+(?:\.[0-9]+)?)/.exec(line);
171
+ if(match){
172
+ const resets=/resets_(?:at|At): ([^·]+)/.exec(line);
173
+ rows.push({label:match[1],percent:Number(match[2]),resetsAt:resets?.[1]?.trim()||''});
174
+ }
175
+ }
176
+ return rows;
177
+ }
178
+ // Session resume opens a separate terminal; no supported in-place Mods session switch is assumed.
179
+ export function sessionResumeCommand(session){
180
+ if(!session?.id||!/^[a-f0-9-]{36}$/i.test(session.id))throw new Error('Invalid session ID');
181
+ if(!session.project||typeof session.project!=='string'||!session.project.startsWith('/'))throw new Error('Original project directory unavailable');
182
+ const quote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
183
+ return `cd ${quote(session.project)} && claude --resume ${quote(session.id)}`;
184
+ }
185
+ export function selectedSessionForAction(s,focusedId){
186
+ if(s.view==='sessions')return s.report?.sessions?.find(x=>x.id===focusedId&&(!s.project||x.project===s.project))||null;
187
+ return s.report?.sessions?.find(x=>x.id===s.session?.id)||s.session||null;
188
+ }
189
+ async function sessionAction($,mode){
190
+ const selected=selectedSessionForAction(state,focusedSessionId);
191
+ try{
192
+ if(!selected)throw new Error('Select a session first');
193
+ if(mode==='copy'){
194
+ const result=await $.process.run(['osascript','-e',`set the clipboard to ${JSON.stringify(selected.id)}`],{timeoutMs:5000});
195
+ if(result.exitCode!==0)throw new Error('Clipboard unavailable');
196
+ sessionActionStatus='Session ID copied';
197
+ }else{
198
+ const command=sessionResumeCommand(selected);
199
+ // osascript argument is encoded as an AppleScript string, never shell-interpolated.
200
+ const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
201
+ const result=await $.process.run(['osascript','-e',script],{timeoutMs:10000});
202
+ if(result.exitCode!==0)throw new Error((result.stderr||'Unable to open Terminal').slice(0,120));
203
+ sessionActionStatus='Opened session in Terminal.app';
204
+ }
205
+ }catch(error){sessionActionStatus=String(error?.message||error).slice(0,140);}
206
+ void $.ui.invalidate('ui.render');
207
+ }
208
+ async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);void checkLimits($);}
70
209
  export function register(on){
71
210
  on('session.start',async ($,e,next)=>{
72
211
  await $.command.register({name:'usage-explorer',description:'Open the native interactive Claude Usage Explorer sidebar',immediate:true});
@@ -77,7 +216,7 @@ export function register(on){
77
216
  if(surfaces.includes('terminal')&&!e.presentation.isFullscreen)return {text:'Use /tui fullscreen, then /usage-explorer. The native sidebar needs fullscreen layout.'};
78
217
  if(surfaces.includes('terminal')&&e.presentation.columns<100)return {text:'Widen the terminal to at least 100 columns, then /usage-explorer.'};
79
218
  await show($);
80
- timer?.cancel();timer=$.clock.every(5000,()=>void refresh($));
219
+ timer?.cancel();timer=$.clock.every(5000,()=>{void refresh($);void checkLimits($);});
81
220
  return {text:'Usage Explorer opened in the sidebar. Navigation and refresh do not call the model.'};
82
221
  });
83
222
  on('ui.close',async ($,e,next)=>{if(e.id===PANE){timer?.cancel();timer=null;}return next(e);});
@@ -86,71 +225,77 @@ export function register(on){
86
225
  if(e.surface!=='terminal'&&e.surface!=='desktop')return next(e);
87
226
  const {Box,Text,Button,Input}=$.ui.resolve(e);
88
227
  const rows=itemsFor();
89
- // Prefer the actual viewport row count when the host exposes it.
90
- // Percent heights are not consistently resolved in Claude terminal panes.
91
- const viewportRows=Number(e.presentation?.rows??e.viewport?.height??e.rows);
92
- const paneHeight=Number.isFinite(viewportRows)&&viewportRows>=16?Math.max(12,viewportRows-3):'100%';
93
- const press=item=>change($,drill(state,item));
228
+ const press=item=>{sessionActionStatus='';if(state.view==='sessions')focusedSessionId=item.id;change($,drill(state,item));};
94
229
  const models=()=>{if(state.view==='models'){change($,back(state));return;}change($,{...state,stack:[...state.stack,{view:state.view,project:state.project,session:state.session,agent:state.agent}],view:'models',query:''});};
95
- const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void refresh($);}}));
230
+ const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void persistPeriod($,id);void refresh($);}}));
96
231
  const action=(label,hotkey,fn)=>Button({key:`action-${label}`,plain:true,label,hotkey,onPress:fn});
97
- const path=[state.project&&name(state.project),state.session?.title,state.agent?.id].filter(Boolean);
232
+ const path=[state.project&&name(state.project),state.session?.title,state.agent?agentLabel(state.agent):null].filter(Boolean);
98
233
  const breadcrumb=`${state.view.toUpperCase()}${path.length?' / '+path.join(' / '):''}`;
99
- const stats=state.report?.overall;
100
- const plan=state.planUsage;
234
+ const stats=state.view==='projects'?state.report?.overall:state.view==='sessions'?state.report?.projects?.find(x=>x.name===state.project)?.stats:state.view==='agents'?state.session?.stats:state.view==='models'?(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||state.report?.overall):state.report?.overall;
235
+ const scopeTotal=state.view==='projects'?total(stats):state.view==='sessions'?total(state.report?.projects?.find(x=>x.name===state.project)?.stats):state.view==='agents'?total(state.session?.stats):state.view==='models'?total(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||stats):total(stats);
101
236
  const scopeModels=modelScope(state).slice().sort((a,b)=>total(b.stats)-total(a.stats));
102
237
  const showModelSummary=state.view!=='models'&&scopeModels.length>0;
103
- const divider=()=>Text({dimColor:true,children:'────────────────────────────────'});
104
- const header=Box({flexDirection:'column',gap:0,children:[
105
- Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'Updating…':'● Auto · 5s'})]}),
106
- Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
107
- stats?Text({bold:true,children:`${compact(total(stats))} tokens · ${price(stats)} est. API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
108
- stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} files`}):null,
109
- ].filter(Boolean)});
110
- const planSection=Box({flexDirection:'column',gap:0,children:[
111
- Text({bold:true,children:'PLAN LIMITS'}),
112
- plan?Text({children:`5-hour ${plan.fiveHour??'—'}% · 7-day ${plan.sevenDay??'—'}%`}):Text({dimColor:true,children:'Desktop usage cache unavailable'}),
113
- plan?Text({dimColor:true,wrap:'truncate-end',children:`${plan.stale?'Cached · May be outdated':'Cached · Recently updated'} · ${new Date(plan.timestamp).toLocaleString()}`}):null,
114
- ].filter(Boolean)});
115
238
  const modelSummary=showModelSummary?Box({flexDirection:'column',children:[
116
- Text({bold:true,children:'MODEL BREAKDOWN'}),
117
- ...scopeModels.slice(0,3).map((model,i)=>Text({key:`model-summary-${i}`,dimColor:true,wrap:'truncate-end',children:`${model.name} · ${compact(total(model.stats))} · ${price(model.stats)}`})),
118
- ...(scopeModels.length>3?[Text({dimColor:true,children:`+${scopeModels.length-3} more (M to view)`})]:[]),
239
+ Text({bold:true,children:'MODEL USAGE'}),
240
+ ...scopeModels.slice(0,4).map((model,i)=>Text({key:`model-summary-${i}`,dimColor:true,wrap:'truncate-end',children:`${model.name} · ${compact(total(model.stats))} tokens · ${price(model.stats)} · ${share(total(model.stats),scopeTotal)}`})),
241
+ ...(scopeModels.length>4?[Text({dimColor:true,children:`+${scopeModels.length-4} more · Select Models to view all`})]:[]),
119
242
  ]}):null;
120
- // Keep the action strip separate from the growing list. Host layout decides
121
- // whether a 100%-height pane is available; do not claim fixed positioning.
243
+ const header=Box({flexDirection:'column',children:[
244
+ Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'⟳ Updating':'● Live · 5s'})]}),
245
+ Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
246
+ stats?Text({bold:true,children:`${compact(total(stats))} tokens ${price(stats)} estimated API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
247
+ stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} transcript files`}):null,
248
+ Text({bold:true,children:'OFFICIAL ACCOUNT USAGE'}),
249
+ ...(officialSpend?[
250
+ Text({key:'official-spend-heading',bold:true,children:`${currencyAmount(officialSpend.used,officialSpend.currency)} / ${currencyAmount(officialSpend.limit,officialSpend.currency)} · ${officialSpend.percent.toFixed(1)}%`}),
251
+ Text({key:'official-spend-bar',children:usageBar(officialSpend.percent,30)}),
252
+ Text({key:'official-spend-left',dimColor:true,children:`${currencyAmount(Math.max(0,officialSpend.limit-officialSpend.used),officialSpend.currency)} remaining · official ${officialSpend.source}`}),
253
+ ]:[]),
254
+ ...officialLimitRows(limitState).filter(l=>!(officialSpend&&l.label==='extra_usage')).flatMap((l,i)=>[
255
+ Text({key:`limit-name-${i}`,dimColor:true,children:`${l.label.replaceAll('_',' ')}${l.resetsAt?' · resets '+l.resetsAt:''}`}),
256
+ Text({key:`limit-bar-${i}`,children:usageBar(l.percent)}),
257
+ ]),
258
+ ...(officialLimitRows(limitState).length===0?[Text({dimColor:true,children:`${limitState.status} · No supported limit values`})]:[]),
259
+ Text({dimColor:true,children:'Official account spend and local API-equivalent estimates are separate.'}),
260
+ Button({key:'check-official-limits',plain:true,label:'↻ Refresh official usage',onPress:()=>void checkLimits($,{force:true})}),
261
+ ].filter(Boolean)});
122
262
  const footer=Box({flexDirection:'column',flexShrink:0,children:[
123
- divider(),
124
- Box({flexDirection:'row',gap:1,flexWrap:'wrap',children:[
263
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
264
+ Box({flexDirection:'row',gap:2,flexWrap:'wrap',children:[
125
265
  action('← Back','h',()=>change($,back(state))),
126
266
  action('↻ Refresh','r',()=>void refresh($)),
127
267
  action('◷ Period','p',()=>change($,{periodMenu:!state.periodMenu})),
128
268
  action(state.view==='models'?'← Close models':'▤ Models','m',models),
129
269
  action(state.sort==='tokens'?'↓ Tokens':'↓ Cost','s',()=>change($,{sort:state.sort==='tokens'?'cost':'tokens'})),
270
+ ...(state.session?[
271
+ action('Open session','o',()=>void sessionAction($,'open')),
272
+ action('Copy ID','c',()=>void sessionAction($,'copy')),
273
+ ]:[]),
130
274
  action('× Exit','x',()=>void $.ui.close({id:PANE})),
131
275
  ]}),
132
- Text({dimColor:true,wrap:'truncate-end',children:'Esc close · H back · R refresh · P period · M models · S sort'}),
276
+ Text({dimColor:true,children:'Esc: close · H: back · O: open current session · C: copy ID · X: exit'}),
133
277
  ]});
134
- return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:paneHeight,justifyContent:'space-between',children:[
278
+ return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:'100%',justifyContent:'space-between',children:[
135
279
  Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
136
- header,
137
- divider(),
138
- planSection,
139
- divider(),
140
- Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
141
- Input({key:'usage-filter',placeholder:'Search projects / sessions…',value:state.query,onInput:v=>change($,{query:v}),onSubmit:v=>change($,{query:typeof v==='string'?v:state.query})}),
142
- ...(state.periodMenu?[
143
- Text({bold:true,children:'CHOOSE PERIOD'}),
144
- Box({flexDirection:'column',gap:0,children:periodButtons}),
145
- divider(),
146
- ]:[]),
147
- ...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
148
- ...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
149
- Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
150
- ...rows.slice(0,80).map((item,i)=>Button({key:`entry-${i}-${item.id}`,plain:true,onPress:()=>press(item),label:`${item.label.slice(0,36)} · ${compact(total(item.stats))} · ${price(item.stats)}`})),
151
- ...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
152
- ]}),
153
- ...(modelSummary?[divider(),modelSummary]:[]),
280
+ header,
281
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
282
+ Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
283
+ Input({key:'usage-filter',placeholder:'Filter entries…',value:state.query,onInput:v=>change($,{query:v}),onSubmit:v=>change($,{query:typeof v==='string'?v:state.query})}),
284
+ ...(state.periodMenu?[
285
+ Text({bold:true,children:'SELECT PERIOD'}),
286
+ Box({flexDirection:'column',gap:0,children:periodButtons}),
287
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
288
+ ]:[]),
289
+ ...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
290
+ ...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
291
+ ...(state.session?[Text({dimColor:true,children:`Session ID: ${state.session.id}`})]:[]),
292
+ ...(state.agent&&state.agent.id!=='main'?[Text({dimColor:true,children:`Agent ID: ${state.agent.id}`})]:[]),
293
+ ...(sessionActionStatus?[Text({dimColor:true,children:sessionActionStatus})]:[]),
294
+ ...(modelSummary?[modelSummary]:[]),
295
+ Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
296
+ ...rows.slice(0,80).map((item,i)=>Button({key:`entry-${i}-${item.id}`,plain:true,onFocus:()=>{if(state.view==='sessions')focusedSessionId=item.id;},onPress:()=>press(item),label:`${item.label.slice(0,40)} · ${compact(total(item.stats))} · ${price(item.stats)} · ${share(total(item.stats),scopeTotal)}`})),
297
+ ...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
298
+ ]}),
154
299
  ].filter(Boolean)}),
155
300
  footer,
156
301
  ]});
package/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "claude-usage-explorer",
3
- "version": "1.0.1",
4
- "description": "Local, read-only Claude Code usage analytics CLI and plugin",
5
- "type": "module",
6
- "bin": {
7
- "claude-usage": "./bin/claude-usage.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test",
11
- "start": "node ./bin/claude-usage.mjs",
12
- "check": "node --check ./src/core.mjs && node --check ./src/statusline.mjs && node --check ./bin/claude-usage.mjs && node --check ./src/tui.mjs && node --check ./src/launcher.mjs && node --check ./src/snapshot.mjs && node --check ./hooks/usage-explorer.mjs && node --check ./src/plan-usage.mjs",
13
- "prepack": "npm run check && npm test"
14
- },
15
- "engines": {
16
- "node": ">=20"
17
- },
18
- "license": "MIT",
19
- "files": [
20
- "bin/",
21
- "src/",
22
- "hooks/",
23
- "commands/",
24
- ".claude-plugin/",
25
- "scripts/",
26
- "README.md",
27
- "LICENSE"
28
- ],
29
- "homepage": "https://github.com/IliasPol/claude-usage#readme",
30
- "repository": {
31
- "type": "git",
32
- "url": "git+https://github.com/IliasPol/claude-usage.git"
33
- },
34
- "bugs": {
35
- "url": "https://github.com/IliasPol/claude-usage/issues"
36
- },
37
- "keywords": [
38
- "claude-code",
39
- "claude",
40
- "usage-tracker",
41
- "token-usage",
42
- "cost-tracking",
43
- "terminal-ui",
44
- "analytics",
45
- "developer-tools",
46
- "cli"
47
- ]
48
- }
1
+ {
2
+ "name": "claude-usage-explorer",
3
+ "version": "1.1.6",
4
+ "description": "Local, read-only Claude Code usage analytics CLI and plugin",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-usage": "./bin/claude-usage.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "start": "node ./bin/claude-usage.mjs",
12
+ "check": "node --check ./src/core.mjs && node --check ./src/statusline.mjs && node --check ./bin/claude-usage.mjs && node --check ./src/tui.mjs && node --check ./src/launcher.mjs && node --check ./src/snapshot.mjs && node --check ./hooks/usage-explorer.mjs && node --check ./src/plan-usage.mjs",
13
+ "prepack": "npm run check && npm test"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "license": "MIT",
19
+ "files": [
20
+ "bin/",
21
+ "src/",
22
+ "hooks/",
23
+ "commands/",
24
+ ".claude-plugin/",
25
+ "scripts/",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "homepage": "https://github.com/IliasPol/claude-usage#readme",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/IliasPol/claude-usage.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/IliasPol/claude-usage/issues"
36
+ },
37
+ "keywords": [
38
+ "claude-code",
39
+ "claude",
40
+ "usage-tracker",
41
+ "token-usage",
42
+ "cost-tracking",
43
+ "terminal-ui",
44
+ "analytics",
45
+ "developer-tools",
46
+ "cli"
47
+ ]
48
+ }
package/src/core.mjs CHANGED
@@ -85,11 +85,12 @@ export function costFor(model,m,rates=DEFAULT_RATES) {
85
85
  return (m.input*rate[0]+m.output*rate[1]+writeCost+m.cacheRead*rate[3])/1e6;
86
86
  }
87
87
  export function parseFile(file, rates=DEFAULT_RATES) {
88
- const id=sessionIdentity(file);const requests=new Map();let title=null,cwd=null,start=null,end=null,sessionId=null;
88
+ const id=sessionIdentity(file);const requests=new Map();let title=null,cwd=null,start=null,end=null,sessionId=null,agentName=null;
89
89
  const bad=readLines(file,r=>{
90
90
  if(!r || typeof r!=='object') return;
91
91
  if(typeof r.cwd==='string') cwd=r.cwd;
92
92
  if(typeof r.sessionId==='string') sessionId=r.sessionId;
93
+ if(id.isSubagent&&!agentName){const candidate=r.agentType||r.agentName||r.agent_type||r.agent_name;if(typeof candidate==='string'&&candidate.trim()&&candidate.length<=100)agentName=candidate.trim();}
93
94
  const ts=Date.parse(r.timestamp);
94
95
  if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
95
96
  if(!title && r.type==='user') {
@@ -112,12 +113,12 @@ export function parseFile(file, rates=DEFAULT_RATES) {
112
113
  if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
113
114
  } else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
114
115
  });
115
- return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
116
+ return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,agentName,title,cwd,start,end,bad,requests:[...requests.values()]};
116
117
  }
117
118
 
118
119
  // Cache derived metadata and token counters only. Never persist transcript text beyond
119
120
  // the short session title. Cache invalidates when file size/mtime changes.
120
- const CACHE_VERSION=3;
121
+ const CACHE_VERSION=4;
121
122
  const cacheDir=()=>path.join(os.homedir(),'.claude-usage-explorer','cache-v3');
122
123
  function readCachedFile(file){
123
124
  const st=fs.statSync(file);
@@ -151,7 +152,8 @@ export function ingest(roots,rates=DEFAULT_RATES) {
151
152
  if(parsed.start!==null) s.start=Math.min(s.start??parsed.start,parsed.start);
152
153
  if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
153
154
  let agent=s.agents.get(parsed.agent);
154
- if(!agent) {agent={id:parsed.agent,requests:[]};s.agents.set(parsed.agent,agent);}
155
+ if(!agent) {agent={id:parsed.agent,name:parsed.agentName||null,requests:[]};s.agents.set(parsed.agent,agent);}
156
+ if(!agent.name&&parsed.agentName)agent.name=parsed.agentName;
155
157
  agent.requests.push(...parsed.requests);
156
158
  }
157
159
  return {sessions:[...sessions.values()],files:files.length,malformed,roots};
@@ -208,7 +210,7 @@ export function summarize(data,window=[-Infinity,Infinity],rates=DEFAULT_RATES)
208
210
  if(!byModel.has(r.model))byModel.set(r.model,ZERO());add(byModel.get(r.model),one);
209
211
  if(!models.has(r.model))models.set(r.model,ZERO());add(models.get(r.model),one);
210
212
  }
211
- if(am.requests){add(stat,am);agents.push({id:a.id,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
213
+ if(am.requests){add(stat,am);agents.push({id:a.id,name:a.name||null,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
212
214
  }
213
215
  if(!stat.requests)continue;
214
216
  const project=s.cwd||'(unknown project)';
package/src/tui.mjs CHANGED
@@ -2,8 +2,9 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import readline from 'node:readline';
5
- import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
5
+ import {spawnSync} from 'node:child_process';
6
6
  import {readPlanUsage} from './plan-usage.mjs';
7
+ import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
7
8
 
8
9
  const periods=[['Current billing cycle','cycle'],['Previous billing cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['Last 7 days','7d'],['Last 30 days','30d'],['This month','month'],['Previous month','previous-month'],['Custom dates','custom']];
9
10
  export function describePeriod(period, settings={}, now=Date.now()){
@@ -25,10 +26,10 @@ function saveSettings(s){fs.mkdirSync(path.dirname(settingFile()),{recursive:tru
25
26
  const sortColumns=[['cost','Est. USD'],['tokens','Tokens'],['requests','Requests'],['input','Input'],['output','Output'],['cacheWrite','Cache write'],['cacheRead','Cache read'],['name','Name']];
26
27
  const value=(r,key)=>key==='tokens'?totalTokens(r.stats):key==='cost'?r.stats.unknownCostRequests?null:r.stats.knownCost:key==='name'?r.label.toLowerCase():r.stats[key];
27
28
  const empty={requests:0,input:0,output:0,cacheWrite:0,cacheRead:0,knownCost:0,unknownCostRequests:0};
28
- export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period='all',planReader=readPlanUsage}={}){
29
- let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
29
+ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all',planReader=readPlanUsage}={}){
30
30
  let planUsage=null,planCheckedAt=0;
31
31
  function refreshPlan(now=Date.now()){if(now-planCheckedAt<4000)return;planCheckedAt=now;try{planUsage=planReader({now})||null;}catch{planUsage=null;}}
32
+ let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
32
33
  let sourceData=data,view='projects',selection=0,offset=0,stack=[],sort='tokens',descending=true,filter='',message='',currentPeriod=period,project=null,session=null,agent=null,report=null;
33
34
  const rates=()=>({...DEFAULT_RATES,...settings.rates});
34
35
  function refresh(){try{report=summarize(sourceData,timeWindow(currentPeriod,Date.now(),settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0),rates());message=report.overall.unknownCostRequests?`${fmt(report.overall.unknownCostRequests)} requests have unknown model prices. Configure rates in Settings (B).`:'';}catch(e){message=e.message;currentPeriod='all';report=summarize(sourceData,undefined,rates());}}
@@ -36,7 +37,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
36
37
  function rows(){let list=[];
37
38
  if(view==='projects')list=report.projects.map(x=>({...x,label:path.basename(x.name)||x.name}));
38
39
  if(view==='sessions')list=report.sessions.filter(x=>!project||x.project===project.name).map(x=>({...x,label:x.title}));
39
- if(view==='agents')list=(session?.agents||[]).map(x=>({...x,label:x.id==='main'?'Main agent':`Subagent ${x.id}`}));
40
+ if(view==='agents')list=(session?.agents||[]).map(x=>({...x,label:x.id==='main'?'Agent (main)':(x.name||x.type||'Subagent')}));
40
41
  if(view==='models')list=(agent?.models||session?.models||project?.models||report.models).map(x=>({...x,label:x.name}));
41
42
  if(view==='periods')list=periods.map(([,v])=>({label:describePeriod(v,settings),value:v,stats:empty}));
42
43
  if(view==='settings')list=[{label:`Billing: day ${settings.billingDay||'not set'} at ${String(settings.billingHour||0).padStart(2,'0')}:${String(settings.billingMinute||0).padStart(2,'0')} ${settings.billingTimeZone||'UTC'}`,value:'billing',stats:empty},{label:`Custom model prices: ${Object.keys(settings.rates||{}).length} configured`,value:'rates',stats:empty},{label:'Rescan history (R)',value:'rescan',stats:empty}];
@@ -52,7 +53,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
52
53
  if(view==='projects'){project=x;nav('sessions');}
53
54
  else if(view==='sessions'){session=x;nav('agents');}
54
55
  else if(view==='agents'){agent=x;nav('models');}
55
- else if(view==='periods'){if(x.value==='custom')return 'custom';if(['cycle','previous-cycle'].includes(x.value)&&!(Number.isInteger(settings.billingDay)&&settings.billingDay>=1&&settings.billingDay<=31))return `billing-required:${x.value}`;currentPeriod=x.value;refresh();restore();}
56
+ else if(view==='periods'){if(x.value==='custom')return 'custom';if(['cycle','previous-cycle'].includes(x.value)&&!(Number.isInteger(settings.billingDay)&&settings.billingDay>=1&&settings.billingDay<=31))return `billing-required:${x.value}`;currentPeriod=x.value;settings.lastPeriod=currentPeriod;saveSettings(settings);refresh();restore();}
56
57
  else if(view==='settings')return x.value;
57
58
  else if(view==='sort'){setSort(x.value);restore();}
58
59
  else if(view==='models')message=`${x.name} · ${fmt(totalTokens(x.stats))} tokens · ${money(x.stats)} estimated USD`;
@@ -75,7 +76,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
75
76
  }
76
77
  function setLiveState(update){liveState={...liveState,...update};}
77
78
  function toggleLive(){liveState.enabled=!liveState.enabled;liveState.lastCheck=Date.now();}
78
- function setPeriod(p){currentPeriod=p;refresh();switchView('projects');}
79
+ function setPeriod(p){currentPeriod=p;settings.lastPeriod=p;saveSettings(settings);refresh();switchView('projects');}
79
80
  function setBillingDay(n,timeZone='UTC',hour=0,minute=0){settings.billingDay=n;settings.billingTimeZone=timeZone;settings.billingHour=hour;settings.billingMinute=minute;saveSettings(settings);refresh();message=`Billing saved: day ${n}, ${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')} ${timeZone}.`;}
80
81
  function setModelRate(model,values){settings.rates??={};settings.rates[model]=values;saveSettings(settings);refresh();message=`Saved custom USD/million-token rates for ${model}.`;}
81
82
  function setFilter(s){filter=s;selection=0;offset=0;}
@@ -94,8 +95,9 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
94
95
  rule();
95
96
  const crumb=['Projects',project&&path.basename(project.name),session&&truncate(session.title,32),agent&&agent.label].filter(Boolean).join(' › ');
96
97
  line(` ${c(crumb,'1')} ${c('PERIOD','90')} ${describePeriod(currentPeriod,settings)} ${c('SORT','90')} ${sort} ${descending?'↓':'↑'}`);
97
- line(` ${c('TOTAL','90')} ${fmt(totalTokens(report.overall))} tokens ${fmt(report.overall.requests)} requests ${c(money(report.overall),'1;32')} API-equivalent`);
98
- const planLabel=planUsage?`5h ${planUsage.fiveHour??'—'}% · 7d ${planUsage.sevenDay??'—'}% · ${Date.now()-planUsage.timestamp>15*60000?'Cached, may be outdated':'Cached, recent'} · ${new Date(planUsage.timestamp).toLocaleString()}`:'Claude Desktop plan usage unavailable';
98
+ const scoped=view==='sessions'?project?.stats:view==='agents'?session?.stats:view==='models'?(agent?.stats||session?.stats||project?.stats||report.overall):report.overall;
99
+ line(` ${c('TOTAL','90')} ${fmt(totalTokens(scoped))} tokens ${fmt(scoped.requests)} requests ${c(money(scoped),'1;32')} API-equivalent`);
100
+ const planLabel=planUsage?`5h ${planUsage.fiveHour??'—'}% · 7d ${planUsage.sevenDay??'—'}% · ${planUsage.stale?'Cached, may be outdated':'Cached, recent'}`:'Claude Desktop plan usage unavailable';
99
101
  line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
100
102
  rule();
101
103
  const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
@@ -109,16 +111,19 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
109
111
  if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
110
112
  // Keep columns aligned by terminal width, shrinking the name first.
111
113
  const columns=width>=135?[['REQ',7,'requests'],['INPUT',10,'input'],['OUTPUT',10,'output'],['C.WRITE',12,'cacheWrite'],['C.READ',13,'cacheRead'],['TOKENS',14,'tokens'],['USD',13,'cost']]:width>=105?[['REQ',7,'requests'],['OUTPUT',11,'output'],['C.WRITE',13,'cacheWrite'],['C.READ',14,'cacheRead'],['TOKENS',15,'tokens'],['USD',12,'cost']]:[['REQ',7,'requests'],['TOKENS',15,'tokens'],['USD',13,'cost']];
112
- const labelWidth=Math.max(17,width-5-columns.reduce((sum,[,n])=>sum+n+1,0));
114
+ const rightMargin=4; // Always leave a few columns clear of the terminal's right edge.
115
+ const labelWidth=Math.max(17,width-5-rightMargin-7-columns.reduce((sum,[,n])=>sum+n+1,0));
113
116
  if(!['periods','settings','sort'].includes(view)){
114
- line(c(' '+pad('NAME',labelWidth)+' '+columns.map(([name,n,key])=>pad(name+(sort===key?(descending?'↓':'↑'):''),n,'right')).join(' '),'1;36'));
117
+ line(c(' '+pad('NAME',labelWidth)+' '+pad('SHARE',6,'right')+' '+columns.map(([name,n,key])=>pad(name+(sort===key?(descending?'↓':'↑'):''),n,'right')).join(' '),'1;36'));
115
118
  }
116
119
  for(let i=offset;i<Math.min(available.length,offset+visible);i++){
117
120
  const x=available[i],active=i===selection;
118
121
  let text;
119
122
  if(['periods','settings','sort'].includes(view))text=` ${active?'❯':' '} ${x.label}${view==='periods'&&x.value===currentPeriod?' ✓':''}`;
120
123
  else {const s=x.stats;const vals={requests:fmt(s.requests),input:fmt(s.input),output:fmt(s.output),cacheWrite:fmt(s.cacheWrite),cacheRead:fmt(s.cacheRead),tokens:fmt(totalTokens(s)),cost:money(s)};
121
- text=` ${active?'❯':' '} ${pad(x.label,labelWidth)} `+columns.map(([,n,key])=>pad(vals[key],n,'right')).join(' ');
124
+ const parentTotal=view==='projects'?totalTokens(report.overall):view==='sessions'?totalTokens(project?.stats):view==='agents'?totalTokens(session?.stats):totalTokens(agent?.stats||report.overall);
125
+ const pct=parentTotal>0?(totalTokens(s)>0&&100*totalTokens(s)/parentTotal<0.05?'<0.1%':(100*totalTokens(s)/parentTotal).toFixed(1)+'%'):'—';
126
+ text=` ${active?'❯':' '} ${pad(x.label,labelWidth)} ${pad(pct,6,'right')} `+columns.map(([,n,key])=>pad(vals[key],n,'right')).join(' ');
122
127
  }
123
128
  line(active?c(truncate(text,width),'7'):truncate(text,width));
124
129
  }
@@ -128,7 +133,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
128
133
  for(let i=renderedRows;i<visible;i++)line('');
129
134
  line(c(` ${available.length?`${offset+1}–${Math.min(available.length,offset+visible)} of ${available.length}`:'0 rows'} • PgUp/PgDn scroll`,'90'));
130
135
  rule();
131
- line(c(' MODEL BREAKDOWN · SELECTED ROW','1;36')+c(' (M: full model table)','90'));
136
+ line(c(' MODELS IN SELECTED ROW','1;36')+c(' (M: full model table)','90'));
132
137
  const models=breakdown?[...breakdown].sort((a,b)=>totalTokens(b.stats)-totalTokens(a.stats)).slice(0,5):[];
133
138
  for(let i=0;i<5;i++){
134
139
  const m=models[i];
@@ -136,12 +141,12 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
136
141
  }
137
142
  rule();
138
143
  line(c(' ↑↓ Navigate ENTER Open BACKSPACE Back M Models D Dates O Sort / Search','1'));
139
- line(c(' B Billing/settings S Reverse sort C Current project R Refresh L Live ? Help Q Quit','90'));
144
+ line(c(' B Settings S Reverse sort C Current project E Open session ? Help Q Quit','90'));
140
145
  line(c(' '+(filter?'Search: '+truncate(filter,width-12):' '),'36'));
141
146
  line(c(` ${liveState.enabled?'● LIVE':'Ⅱ PAUSED'} · Updated ${Math.max(0,Math.floor((Date.now()-liveState.lastUpdated)/1000))}s ago${liveState.error?' · '+liveState.error:''}`,'36')+(message?' '+c(truncate(message,Math.max(10,width-65)),'33'):''));
142
147
  return out.slice(0,Math.max(1,height-1)).join('\n');
143
148
  }
144
- return {render,refreshPlan,get planUsage(){return planUsage;},rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
149
+ return {render,refreshPlan,get planUsage(){return planUsage;},rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get session(){return session;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
145
150
  }
146
151
  // Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
147
152
  export function clipAnsi(value, maxWidth) {
@@ -185,8 +190,7 @@ export async function launchTui(argv=[]){
185
190
  checking=true;
186
191
  try{
187
192
  const next=historySignature(roots);
188
- if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;}
189
- explorer.refreshPlan();draw();
193
+ if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;draw();}
190
194
  explorer.setLiveState({lastCheck:Date.now()});
191
195
  }catch(e){explorer.setLiveState({error:e.message});draw();}
192
196
  finally{checking=false;}
@@ -229,11 +233,31 @@ export async function launchTui(argv=[]){
229
233
  else if(str==='b')explorer.nav('settings');
230
234
  else if(str==='o')explorer.nav('sort');
231
235
  else if(str==='s')explorer.setSort(explorer.sort);
236
+ else if(str==='C'){
237
+ const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
238
+ if(selected?.id&&process.platform==='darwin'){
239
+ const result=spawnSync('pbcopy',[],{input:selected.id,encoding:'utf8'});
240
+ explorer.message=result.status===0?'Session ID copied.':'Unable to copy session ID.';
241
+ }else explorer.message='Select a session on macOS to copy its ID.';
242
+ }
243
+ else if(str==='e'){
244
+ const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
245
+ const directory=selected?.project||explorer.project?.name;
246
+ if(!selected?.id||!directory||process.platform!=='darwin')explorer.message='External resume requires a selected session on macOS.';
247
+ else if(!fs.existsSync(directory))explorer.message='Original project directory is missing.';
248
+ else {
249
+ const quote=x=>"'"+String(x).replaceAll("'","'\\''")+"'";
250
+ const command=`cd ${quote(directory)} && claude --resume ${quote(selected.id)}`;
251
+ const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
252
+ const result=spawnSync('osascript',['-e',script],{encoding:'utf8',timeout:10000});
253
+ explorer.message=result.status===0?'Opened in Terminal.app.':`Unable to open Terminal: ${(result.stderr||'').slice(0,80)}`;
254
+ }
255
+ }
232
256
  else if(str==='c'){explorer.switchView('projects');const i=explorer.rows().findIndex(x=>path.resolve(x.name).toLowerCase()===path.resolve(explorer.cwd).toLowerCase());if(i>=0){explorer.selection=i;explorer.open();}else explorer.message='No exact match for current directory.';}
233
257
  else if(str==='/')explorer.setFilter(await ask('Filter table (empty clears):'));
234
258
  else if(str==='r'){explorer.rescan(roots);lastSignature=historySignature(roots);}
235
259
  else if(str==='l')explorer.toggleLive();
236
- else if(str==='?'){await ask('HELP\n↑↓ select rows; Enter drills down; Backspace goes back.\nM shows models at the current level.\nD selects date range; B configures billing and model prices.\nO chooses a sort column; S reverses direction.\n/ filters; C opens current project; R rescans; L toggles live updates; Q quits.\n\nPress Enter to return.');}
260
+ else if(str==='?'){await ask('HELP\n↑↓ select rows; Enter drills down; Backspace goes back.\nM shows models at the current level.\nD selects date range; B configures billing and model prices.\nO chooses a sort column; S reverses direction.\n/ filters; C copies session ID; E opens selected session externally; R rescans; L toggles live updates; Q quits.\n\nPress Enter to return.');}
237
261
  draw();
238
262
  }
239
263
  process.stdin.on('keypress',onKey);process.stdout.on('resize',resize);resize();