claude-usage-explorer 1.0.0 → 1.1.5
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/.claude-plugin/plugin.json +1 -1
- package/README.md +125 -84
- package/bin/claude-usage.mjs +4 -1
- package/claude-usage +20 -0
- package/hooks/usage-explorer.mjs +200 -63
- package/package.json +18 -29
- package/src/core.mjs +9 -5
- package/src/snapshot.mjs +1 -2
- package/src/tui.mjs +39 -21
- package/tests/core.test.mjs +30 -0
- package/tests/launcher.test.mjs +12 -0
- package/tests/mod.test.mjs +177 -0
- package/tests/official-spend.test.mjs +17 -0
- package/tests/render.test.mjs +23 -0
- package/tests/snapshot.test.mjs +15 -0
- package/tests/statusline.test.mjs +19 -0
- package/tests/tui.test.mjs +104 -0
- package/commands/tui.md +0 -12
- package/src/plan-usage.mjs +0 -39
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {createExplorer,describePeriod} from '../src/tui.mjs';
|
|
4
|
+
const z={input:3,output:2,cacheRead:5,cacheWrite:1,model:'claude-sonnet-4-5',timestamp:Date.now()};
|
|
5
|
+
const data={sessions:[{id:'s1',title:'Example session',cwd:'/tmp/demo',start:Date.now(),end:Date.now(),agents:new Map([['main',{id:'main',requests:[z]}]])}],files:1,malformed:0,roots:[]};
|
|
6
|
+
test('TUI drills from project to session to agent to model without LLM',()=>{
|
|
7
|
+
const x=createExplorer(data,{period:'all',cwd:'/tmp/demo',settings:{}});
|
|
8
|
+
assert.equal(x.rows().length,1);
|
|
9
|
+
assert.match(x.render(115,30),/CLAUDE USAGE EXPLORER/);
|
|
10
|
+
x.open();assert.equal(x.view,'sessions');assert.equal(x.rows()[0].title,'Example session');
|
|
11
|
+
x.open();assert.equal(x.view,'agents');assert.equal(x.rows()[0].id,'main');
|
|
12
|
+
x.open();assert.equal(x.view,'models');assert.equal(x.rows()[0].name,'claude-sonnet-4-5');
|
|
13
|
+
x.restore();assert.equal(x.view,'agents');
|
|
14
|
+
});
|
|
15
|
+
test('TUI switches dates and shows model columns',()=>{
|
|
16
|
+
const x=createExplorer(data,{period:'all',settings:{}});
|
|
17
|
+
x.nav('periods');const i=x.rows().findIndex(x=>x.value==='today');x.selection=i;x.open();
|
|
18
|
+
assert.equal(x.currentPeriod,'today');assert.match(x.render(120,30),/C.READ/);
|
|
19
|
+
});
|
|
20
|
+
test('TUI shows models for selected project without drilling into sessions',()=>{
|
|
21
|
+
const x=createExplorer(data,{period:'all',settings:{}});
|
|
22
|
+
assert.match(x.render(130,32),/MODELS IN SELECTED ROW/);
|
|
23
|
+
assert.match(x.render(130,32),/claude-sonnet-4-5/);
|
|
24
|
+
x.setSort('output');assert.equal(x.sort,'output');
|
|
25
|
+
});
|
|
26
|
+
test('TUI displays unknown model pricing as N/A, never a misleading zero',()=>{
|
|
27
|
+
const unknown={...z,model:'claude-unknown-9-9'};
|
|
28
|
+
const d={...data,sessions:[{...data.sessions[0],agents:new Map([['main',{id:'main',requests:[unknown]}]])}]};
|
|
29
|
+
const x=createExplorer(d,{period:'all',settings:{}});
|
|
30
|
+
assert.match(x.render(130,32),/N\/A/);
|
|
31
|
+
});
|
|
32
|
+
test('TUI footer and model panel remain at fixed rows as selection changes',()=>{
|
|
33
|
+
const extra={...data.sessions[0],id:'s2',title:'Another session',cwd:'/tmp/other'};
|
|
34
|
+
const x=createExplorer({...data,sessions:[...data.sessions,extra]},{period:'all',settings:{}});
|
|
35
|
+
const a=x.render(120,32).split('\n');
|
|
36
|
+
x.selection=1;
|
|
37
|
+
const b=x.render(120,32).split('\n');
|
|
38
|
+
for(const label of ['MODELS IN SELECTED ROW','Billing/settings','PgUp/PgDn scroll']){
|
|
39
|
+
const index=lines=>lines.findIndex(line=>line.includes(label));
|
|
40
|
+
assert.equal(index(a),index(b),label);
|
|
41
|
+
}
|
|
42
|
+
assert.equal(a.length,b.length);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('billing periods appear first and require configuration before selection',()=>{
|
|
46
|
+
const x=createExplorer(data,{period:'all',settings:{}});
|
|
47
|
+
x.nav('periods');
|
|
48
|
+
assert.equal(x.rows()[0].value,'cycle');
|
|
49
|
+
assert.equal(x.rows()[1].value,'previous-cycle');
|
|
50
|
+
assert.equal(x.open(),'billing-required:cycle');
|
|
51
|
+
assert.equal(x.currentPeriod,'all');
|
|
52
|
+
x.setBillingDay(5,'Europe/Athens',10,30);
|
|
53
|
+
assert.equal(x.open(),null);
|
|
54
|
+
assert.equal(x.currentPeriod,'cycle');
|
|
55
|
+
});
|
|
56
|
+
test('custom date selection requests input instead of navigating or quitting',()=>{
|
|
57
|
+
const x=createExplorer(data,{period:'all',settings:{}});
|
|
58
|
+
x.nav('periods');
|
|
59
|
+
x.selection=x.rows().findIndex(r=>r.value==='custom');
|
|
60
|
+
assert.equal(x.open(),'custom');
|
|
61
|
+
assert.equal(x.view,'periods');
|
|
62
|
+
x.setPeriod('2026-11-01:2026-11-05');
|
|
63
|
+
assert.equal(x.currentPeriod,'2026-11-01:2026-11-05');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('billing date choices and header show descriptive date ranges',()=>{
|
|
67
|
+
const settings={billingDay:1,billingTimeZone:'UTC',billingHour:0,billingMinute:0};
|
|
68
|
+
assert.equal(describePeriod('cycle',settings,Date.UTC(2026,10,5)),'Current billing cycle · Nov 1, 2026 – Nov 30, 2026');
|
|
69
|
+
assert.equal(describePeriod('previous-cycle',settings,Date.UTC(2026,10,5)),'Previous billing cycle · Oct 1, 2026 – Oct 31, 2026');
|
|
70
|
+
assert.match(describePeriod('cycle',{},Date.UTC(2026,10,5)),/Set renewal date/);
|
|
71
|
+
const x=createExplorer(data,{settings,period:'cycle'});
|
|
72
|
+
assert.match(x.render(130,32),/Current billing cycle/);
|
|
73
|
+
x.nav('periods');
|
|
74
|
+
assert.match(x.rows()[0].label,/Nov/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('live refresh preserves project, session, sorting, and period',()=>{
|
|
78
|
+
const x=createExplorer(data,{cwd:'/tmp/demo',settings:{}});
|
|
79
|
+
x.open();x.open();
|
|
80
|
+
x.setSort('output');x.setPeriod('today');
|
|
81
|
+
// Date changes intentionally reset the view; navigate back into the session.
|
|
82
|
+
x.open();x.open();
|
|
83
|
+
assert.equal(x.view,'agents');
|
|
84
|
+
x.rescan([], {automatic:true});
|
|
85
|
+
assert.equal(x.currentPeriod,'today');
|
|
86
|
+
assert.equal(x.sort,'output');
|
|
87
|
+
assert.equal(x.view,'projects'); // source was removed by rescan
|
|
88
|
+
x.toggleLive();assert.equal(x.liveState.enabled,false);
|
|
89
|
+
x.toggleLive();assert.equal(x.liveState.enabled,true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('file signature changes only when JSONL metadata changes', async()=>{
|
|
93
|
+
const {historySignature}=await import('../src/tui.mjs');
|
|
94
|
+
const fs=await import('node:fs');
|
|
95
|
+
const os=await import('node:os');
|
|
96
|
+
const path=await import('node:path');
|
|
97
|
+
const root=fs.mkdtempSync(path.join(os.tmpdir(),'claude-live-'));
|
|
98
|
+
try{
|
|
99
|
+
const file=path.join(root,'a.jsonl');
|
|
100
|
+
fs.writeFileSync(file,'a');const a=historySignature([root]);
|
|
101
|
+
assert.equal(a,historySignature([root]));
|
|
102
|
+
fs.appendFileSync(file,'bc');assert.notEqual(a,historySignature([root]));
|
|
103
|
+
}finally{fs.rmSync(root,{recursive:true,force:true});}
|
|
104
|
+
});
|
package/commands/tui.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Launch the interactive Claude Usage Explorer in a separate terminal window, without model calls for navigation
|
|
3
|
-
allowed-tools: Bash
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Launch the local TUI sidecar once, without asking the user any questions. Use the following command via Bash, passing the current project directory as CLAUDE_PROJECT_DIR when available:
|
|
7
|
-
|
|
8
|
-
```sh
|
|
9
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" open
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Do not run the `tui` command directly in the Bash tool (it needs an interactive terminal). The `open` command starts a separate terminal process and exits immediately. Report only whether the launch succeeded. This slash command requires one Claude invocation; subsequent navigation and refreshes use zero model calls. Do not pretend it embeds inside the Claude Code conversation. If a terminal emulator is unsupported, advise running `node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" tui` in a separate terminal.
|
package/src/plan-usage.mjs
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
// Claude Desktop's plan-usage-history.json is undocumented, optional cached data.
|
|
5
|
-
// Read only known numeric utilization fields; never expose organization identifiers.
|
|
6
|
-
export function planUsageFromSamples(samples, now=Date.now()) {
|
|
7
|
-
if (!Array.isArray(samples)) return null;
|
|
8
|
-
const valid=samples.filter(s=>s && Number.isFinite(s.t) && s.u &&
|
|
9
|
-
(Number.isFinite(s.u.fh)||Number.isFinite(s.u.sd)))
|
|
10
|
-
.map(s=>({timestamp:s.t<1e12?s.t*1000:s.t,
|
|
11
|
-
fiveHour:Number.isFinite(s.u.fh)?s.u.fh:null,
|
|
12
|
-
sevenDay:Number.isFinite(s.u.sd)?s.u.sd:null}))
|
|
13
|
-
.filter(s=>s.timestamp>0 && s.timestamp<=now+86400000)
|
|
14
|
-
.sort((a,b)=>a.timestamp-b.timestamp);
|
|
15
|
-
if (!valid.length)return null;
|
|
16
|
-
const latest=valid.at(-1);
|
|
17
|
-
return { ...latest, stale:now-latest.timestamp>15*60*1000,
|
|
18
|
-
samples:valid.slice(-100),source:'Claude Desktop cached history'};
|
|
19
|
-
}
|
|
20
|
-
export function readPlanUsage({platform=process.platform,localAppData=process.env.LOCALAPPDATA,home=process.env.HOME,now=Date.now()}={}){
|
|
21
|
-
const roots=[];
|
|
22
|
-
if(platform==='win32'&&localAppData)roots.push(path.join(localAppData,'Claude-Data'));
|
|
23
|
-
if(platform==='darwin'&&home){roots.push(path.join(home,'Library','Application Support','Claude-Data'));roots.push(path.join(home,'Library','Application Support','Claude'));}
|
|
24
|
-
let best=null;
|
|
25
|
-
for(const root of roots){
|
|
26
|
-
let folders=[];
|
|
27
|
-
try{folders=fs.readdirSync(root,{withFileTypes:true}).filter(x=>x.isDirectory()&&x.name.startsWith('claude_')).map(x=>path.join(root,x.name));}catch{continue;}
|
|
28
|
-
for(const folder of folders){
|
|
29
|
-
try{
|
|
30
|
-
const file=path.join(folder,'plan-usage-history.json');
|
|
31
|
-
if(fs.statSync(file).size>2_000_000)continue;
|
|
32
|
-
const data=JSON.parse(fs.readFileSync(file,'utf8'));
|
|
33
|
-
const result=planUsageFromSamples(data.samples,now);
|
|
34
|
-
if(result&&(!best||result.timestamp>best.timestamp))best=result;
|
|
35
|
-
}catch{}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return best;
|
|
39
|
-
}
|