claude-usage-explorer 0.0.0-stage → 1.0.1
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/marketplace.json +30 -0
- package/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.md +116 -3
- package/bin/claude-usage.mjs +7 -0
- package/commands/open.md +12 -0
- package/commands/tui.md +12 -0
- package/hooks/hooks.json +5 -0
- package/hooks/usage-explorer.mjs +159 -0
- package/package.json +48 -6
- package/scripts/install-command.sh +12 -0
- package/src/core.mjs +285 -0
- package/src/launcher.mjs +30 -0
- package/src/plan-usage.mjs +39 -0
- package/src/snapshot.mjs +18 -0
- package/src/statusline.mjs +59 -0
- package/src/tui.mjs +260 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Claude Usage Explorer contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/claude-usage-explorer)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## Preview
|
|
9
|
+
|
|
10
|
+
### Native Claude Code sidebar
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
Use `/usage-explorer` to inspect local usage without leaving your Claude Code session.
|
|
15
|
+
|
|
16
|
+
### Standalone terminal dashboard
|
|
17
|
+
|
|
18
|
+

|
|
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.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from '../src/core.mjs';
|
|
3
|
+
import { launchTui } from '../src/tui.mjs';
|
|
4
|
+
import { snapshot } from '../src/snapshot.mjs';
|
|
5
|
+
import {runStatusLine,installStatusLine} from '../src/statusline.mjs';
|
|
6
|
+
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; });
|
package/commands/open.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Open 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/commands/tui.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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/hooks/hooks.json
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Claude Code native mod. The UI uses host components; local analytics run in a child Node process.
|
|
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.
|
|
5
|
+
export function scriptPathFromModuleUrl(moduleUrl){
|
|
6
|
+
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;
|
|
11
|
+
}
|
|
12
|
+
const PANE='claude-usage-explorer';
|
|
13
|
+
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};
|
|
15
|
+
let timer=null;
|
|
16
|
+
let inFlight=null;
|
|
17
|
+
const total=s=>(s?.input||0)+(s?.output||0)+(s?.cacheRead||0)+(s?.cacheWrite||0);
|
|
18
|
+
const compact=n=>Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n||0);
|
|
19
|
+
const price=s=>s?.unknownCostRequests?'N/A':`$${(s?.knownCost||0).toFixed(2)}`;
|
|
20
|
+
const name=p=>String(p||'').replace(/\\/g,'/').split('/').filter(Boolean).pop()||p;
|
|
21
|
+
const change=($,patch)=>{state={...state,...patch};void $.ui.invalidate('ui.render');};
|
|
22
|
+
export function itemsFor(s=state){
|
|
23
|
+
const r=s.report;
|
|
24
|
+
if(!r)return [];
|
|
25
|
+
let items=[];
|
|
26
|
+
if(s.view==='projects')items=r.projects.map(x=>({id:x.name,label:name(x.name),stats:x.stats,source:x}));
|
|
27
|
+
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}));}
|
|
29
|
+
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
|
+
if(s.query)items=items.filter(x=>x.label.toLowerCase().includes(s.query.toLowerCase()));
|
|
31
|
+
return items.sort((a,b)=>s.sort==='cost'?(b.stats.knownCost-a.stats.knownCost):total(b.stats)-total(a.stats));
|
|
32
|
+
}
|
|
33
|
+
export function drill(s,item){
|
|
34
|
+
const previous={view:s.view,project:s.project,session:s.session,agent:s.agent};
|
|
35
|
+
if(s.view==='projects')return {...s,stack:[...s.stack,previous],view:'sessions',project:item.id,session:null,agent:null,query:''};
|
|
36
|
+
if(s.view==='sessions')return {...s,stack:[...s.stack,previous],view:'agents',session:item.source,agent:null,query:''};
|
|
37
|
+
if(s.view==='agents')return {...s,stack:[...s.stack,previous],view:'models',agent:item.source,query:''};
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
export function modelScope(s){
|
|
41
|
+
const r=s.report;
|
|
42
|
+
if(!r)return [];
|
|
43
|
+
const session=r.sessions.find(x=>x.id===s.session?.id)||s.session;
|
|
44
|
+
const agent=session?.agents?.find(x=>x.id===s.agent?.id)||s.agent;
|
|
45
|
+
const project=r.projects.find(x=>x.name===s.project);
|
|
46
|
+
return agent?.models||session?.models||project?.models||r.models||[];
|
|
47
|
+
}
|
|
48
|
+
export function back(s){
|
|
49
|
+
if(!s.stack.length)return {...s,view:'projects',project:null,session:null,agent:null,query:''};
|
|
50
|
+
const last=s.stack[s.stack.length-1];
|
|
51
|
+
return {...s,...last,stack:s.stack.slice(0,-1),query:''};
|
|
52
|
+
}
|
|
53
|
+
async function refresh($){
|
|
54
|
+
if(inFlight)return inFlight;
|
|
55
|
+
change($,{loading:true,error:''});
|
|
56
|
+
inFlight=(async()=>{
|
|
57
|
+
try {
|
|
58
|
+
// Module location resolves to the plugin's installed directory, independent of cwd.
|
|
59
|
+
const script=scriptPathFromModuleUrl(import.meta.url);
|
|
60
|
+
const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
|
|
61
|
+
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
|
|
62
|
+
const snap=JSON.parse(result.stdout);
|
|
63
|
+
// 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:''});
|
|
65
|
+
}catch(err){change($,{loading:false,error:String(err?.message||err).slice(0,260)});}
|
|
66
|
+
})().finally(()=>{inFlight=null});
|
|
67
|
+
return inFlight;
|
|
68
|
+
}
|
|
69
|
+
async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);}
|
|
70
|
+
export function register(on){
|
|
71
|
+
on('session.start',async ($,e,next)=>{
|
|
72
|
+
await $.command.register({name:'usage-explorer',description:'Open the native interactive Claude Usage Explorer sidebar',immediate:true});
|
|
73
|
+
return next(e);
|
|
74
|
+
});
|
|
75
|
+
on('command.run',{command:'usage-explorer'},async ($,e)=>{
|
|
76
|
+
const surfaces=await $.session.surfaces().catch(()=>[]);
|
|
77
|
+
if(surfaces.includes('terminal')&&!e.presentation.isFullscreen)return {text:'Use /tui fullscreen, then /usage-explorer. The native sidebar needs fullscreen layout.'};
|
|
78
|
+
if(surfaces.includes('terminal')&&e.presentation.columns<100)return {text:'Widen the terminal to at least 100 columns, then /usage-explorer.'};
|
|
79
|
+
await show($);
|
|
80
|
+
timer?.cancel();timer=$.clock.every(5000,()=>void refresh($));
|
|
81
|
+
return {text:'Usage Explorer opened in the sidebar. Navigation and refresh do not call the model.'};
|
|
82
|
+
});
|
|
83
|
+
on('ui.close',async ($,e,next)=>{if(e.id===PANE){timer?.cancel();timer=null;}return next(e);});
|
|
84
|
+
on('session.end',async ($,e,next)=>{timer?.cancel();timer=null;return next(e);});
|
|
85
|
+
on('ui.render',{component:'Pane',requestId:PANE},($,e,next)=>{
|
|
86
|
+
if(e.surface!=='terminal'&&e.surface!=='desktop')return next(e);
|
|
87
|
+
const {Box,Text,Button,Input}=$.ui.resolve(e);
|
|
88
|
+
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));
|
|
94
|
+
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($);}}));
|
|
96
|
+
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);
|
|
98
|
+
const breadcrumb=`${state.view.toUpperCase()}${path.length?' / '+path.join(' / '):''}`;
|
|
99
|
+
const stats=state.report?.overall;
|
|
100
|
+
const plan=state.planUsage;
|
|
101
|
+
const scopeModels=modelScope(state).slice().sort((a,b)=>total(b.stats)-total(a.stats));
|
|
102
|
+
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
|
+
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)`})]:[]),
|
|
119
|
+
]}):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.
|
|
122
|
+
const footer=Box({flexDirection:'column',flexShrink:0,children:[
|
|
123
|
+
divider(),
|
|
124
|
+
Box({flexDirection:'row',gap:1,flexWrap:'wrap',children:[
|
|
125
|
+
action('← Back','h',()=>change($,back(state))),
|
|
126
|
+
action('↻ Refresh','r',()=>void refresh($)),
|
|
127
|
+
action('◷ Period','p',()=>change($,{periodMenu:!state.periodMenu})),
|
|
128
|
+
action(state.view==='models'?'← Close models':'▤ Models','m',models),
|
|
129
|
+
action(state.sort==='tokens'?'↓ Tokens':'↓ Cost','s',()=>change($,{sort:state.sort==='tokens'?'cost':'tokens'})),
|
|
130
|
+
action('× Exit','x',()=>void $.ui.close({id:PANE})),
|
|
131
|
+
]}),
|
|
132
|
+
Text({dimColor:true,wrap:'truncate-end',children:'Esc close · H back · R refresh · P period · M models · S sort'}),
|
|
133
|
+
]});
|
|
134
|
+
return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:paneHeight,justifyContent:'space-between',children:[
|
|
135
|
+
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]:[]),
|
|
154
|
+
].filter(Boolean)}),
|
|
155
|
+
footer,
|
|
156
|
+
]});
|
|
157
|
+
|
|
158
|
+
});
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-usage-explorer",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Install a stable wrapper, not a symlink: symlinks break dirname-based launchers.
|
|
3
|
+
set -eu
|
|
4
|
+
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
5
|
+
mkdir -p "$HOME/.local/bin"
|
|
6
|
+
TARGET="$HOME/.local/bin/claude-usage"
|
|
7
|
+
# Quote the installation directory safely for POSIX sh.
|
|
8
|
+
QUOTED_ROOT=$(printf '%s' "$ROOT" | sed "s/'/'\\''/g")
|
|
9
|
+
printf '#!/bin/sh\nexec '''%s/claude-usage''' "$@"\n' "$QUOTED_ROOT" > "$TARGET"
|
|
10
|
+
chmod +x "$TARGET"
|
|
11
|
+
printf 'Installed: %s -> %s/claude-usage\n' "$TARGET" "$ROOT"
|
|
12
|
+
printf '%s\n' 'Put ~/.local/bin before ~/.asdf/shims in PATH, then run: hash -r'
|
package/src/core.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const day = 86400000;
|
|
7
|
+
export const DEFAULT_RATES = {
|
|
8
|
+
// USD per million tokens. Indicative defaults, not an authoritative historical price table.
|
|
9
|
+
'claude-opus-4': [15,75,18.75,1.5],
|
|
10
|
+
'claude-opus-4-1': [15,75,18.75,1.5],
|
|
11
|
+
'claude-opus-4-5': [5,25,6.25,0.5],
|
|
12
|
+
'claude-opus-4-6': [5,25,6.25,0.5],
|
|
13
|
+
'claude-sonnet-4': [3,15,3.75,0.3],
|
|
14
|
+
'claude-sonnet-4-5': [3,15,3.75,0.3],
|
|
15
|
+
'claude-sonnet-4-6': [3,15,3.75,0.3],
|
|
16
|
+
// Official standard 5-minute-cache API rates (USD / million), checked 2026-11-05.
|
|
17
|
+
'claude-sonnet-5-5': [2,10,2.5,0.1],
|
|
18
|
+
'claude-sonnet-5': [2,10,2.5,0.2],
|
|
19
|
+
'claude-opus-5-5': [4,20,5,0.2],
|
|
20
|
+
'claude-opus-5': [5,25,6.25,0.5],
|
|
21
|
+
'claude-haiku-5-5': [0.1,0.5,0.125,0.01],
|
|
22
|
+
'claude-haiku-4-5': [1,5,1.25,0.1],
|
|
23
|
+
'claude-3-7-sonnet': [3,15,3.75,0.3],
|
|
24
|
+
'claude-3-5-sonnet': [3,15,3.75,0.3],
|
|
25
|
+
'claude-3-5-haiku': [0.8,4,1,0.08],
|
|
26
|
+
};
|
|
27
|
+
const ZERO = () => ({ input:0, output:0, cacheWrite:0, cacheRead:0, requests:0, knownCost:0, unknownCostRequests:0 });
|
|
28
|
+
export function add(a,b) { for (const k of Object.keys(a)) a[k] += b[k] ?? 0; return a; }
|
|
29
|
+
export function totalTokens(a) { return a.input+a.output+a.cacheWrite+a.cacheRead; }
|
|
30
|
+
export function money(n) { return '$'+n.toFixed(4); }
|
|
31
|
+
export function number(n) { return Math.round(n).toLocaleString('en-US'); }
|
|
32
|
+
export function projectRoots(env=process.env,home=os.homedir()) {
|
|
33
|
+
const roots = [env.CLAUDE_CONFIG_DIR, path.join(home,'.claude')].filter(Boolean).map(x=>path.resolve(x,'projects'));
|
|
34
|
+
return [...new Set(roots)];
|
|
35
|
+
}
|
|
36
|
+
export function walk(dir, result=[], depth=0) {
|
|
37
|
+
if(depth>12) return result;
|
|
38
|
+
let items; try { items=fs.readdirSync(dir,{withFileTypes:true}); } catch { return result; }
|
|
39
|
+
for(const item of items) {
|
|
40
|
+
const f=path.join(dir,item.name);
|
|
41
|
+
if(item.isDirectory()) walk(f,result,depth+1);
|
|
42
|
+
else if(item.isFile() && item.name.endsWith('.jsonl')) result.push(f);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
export function readLines(file,onRecord) {
|
|
47
|
+
// Synchronous bounded-memory line iterator for portability; skips malformed and incomplete lines.
|
|
48
|
+
const fd=fs.openSync(file,'r'); const buf=Buffer.allocUnsafe(128*1024); let carry=''; let bad=0;
|
|
49
|
+
const decoder=new TextDecoder('utf-8');
|
|
50
|
+
try { let n; while((n=fs.readSync(fd,buf,0,buf.length,null))>0) {
|
|
51
|
+
carry += decoder.decode(buf.subarray(0,n),{stream:true});
|
|
52
|
+
let pos; while((pos=carry.indexOf('\n'))!==-1) {
|
|
53
|
+
const line=carry.slice(0,pos);carry=carry.slice(pos+1);
|
|
54
|
+
if(!line.trim()) continue;
|
|
55
|
+
try {onRecord(JSON.parse(line));} catch {bad++;}
|
|
56
|
+
}
|
|
57
|
+
// A single exceptionally large line is possible; keep it until newline, never truncate silently.
|
|
58
|
+
}
|
|
59
|
+
carry+=decoder.decode();
|
|
60
|
+
if(carry.trim()) {try {onRecord(JSON.parse(carry));} catch {bad++;}}
|
|
61
|
+
} finally {fs.closeSync(fd);} return bad;
|
|
62
|
+
}
|
|
63
|
+
export function sessionIdentity(file) {
|
|
64
|
+
const name=path.basename(file,'.jsonl');
|
|
65
|
+
const segments=file.split(path.sep);
|
|
66
|
+
const idx=segments.lastIndexOf('subagents');
|
|
67
|
+
return {id:idx>=0 ? segments[idx-1] : name, agent:idx>=0 ? name.replace(/^agent-/,'') : 'main', isSubagent:idx>=0};
|
|
68
|
+
}
|
|
69
|
+
function metrics(usage) {
|
|
70
|
+
const u=usage||{};
|
|
71
|
+
return {input:Number(u.input_tokens)||0,output:Number(u.output_tokens)||0,
|
|
72
|
+
cacheWrite:Number(u.cache_creation_input_tokens)||0,cacheRead:Number(u.cache_read_input_tokens)||0,
|
|
73
|
+
cacheWrite5m:Number(u.cache_creation?.ephemeral_5m_input_tokens)||0,cacheWrite1h:Number(u.cache_creation?.ephemeral_1h_input_tokens)||0};
|
|
74
|
+
}
|
|
75
|
+
function normalizeModel(model) {
|
|
76
|
+
if(!model || typeof model!=='string' || model.startsWith('<')) return null;
|
|
77
|
+
return model.replace(/-20\d{6}$/, '');
|
|
78
|
+
}
|
|
79
|
+
export function costFor(model,m,rates=DEFAULT_RATES) {
|
|
80
|
+
const normalized=normalizeModel(model);const rate=normalized && rates[normalized];
|
|
81
|
+
if(!rate) return null;
|
|
82
|
+
// Prefer tier-specific cache creation counters when present; fallback to aggregate.
|
|
83
|
+
const w5=Number(m.cacheWrite5m)||0,w1=Number(m.cacheWrite1h)||0;
|
|
84
|
+
const writeCost=(w5||w1) ? w5*rate[2]+w1*(rate[4]??rate[2]*1.6) : m.cacheWrite*rate[2];
|
|
85
|
+
return (m.input*rate[0]+m.output*rate[1]+writeCost+m.cacheRead*rate[3])/1e6;
|
|
86
|
+
}
|
|
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;
|
|
89
|
+
const bad=readLines(file,r=>{
|
|
90
|
+
if(!r || typeof r!=='object') return;
|
|
91
|
+
if(typeof r.cwd==='string') cwd=r.cwd;
|
|
92
|
+
if(typeof r.sessionId==='string') sessionId=r.sessionId;
|
|
93
|
+
const ts=Date.parse(r.timestamp);
|
|
94
|
+
if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
|
|
95
|
+
if(!title && r.type==='user') {
|
|
96
|
+
const c=r.message?.content;
|
|
97
|
+
const s=typeof c==='string' ? c : Array.isArray(c)? c.filter(x=>x?.type==='text').map(x=>x.text).join(' '):'';
|
|
98
|
+
const trimmed=s?.replace(/\s+/g,' ').trim();
|
|
99
|
+
if(trimmed && !trimmed.startsWith('<') && !trimmed.startsWith('[Request interrupted') && !trimmed.startsWith('<command-name>')) title=trimmed.slice(0,110);
|
|
100
|
+
}
|
|
101
|
+
if(r.type==='custom-title' && typeof r.customTitle==='string') title=r.customTitle;
|
|
102
|
+
if(r.type!=='assistant') return;
|
|
103
|
+
const msg=r.message||{};if(!msg.usage || !msg.id) return;
|
|
104
|
+
const model=normalizeModel(msg.model);if(!model) return;
|
|
105
|
+
const m=metrics(msg.usage);
|
|
106
|
+
const key=String(msg.id);
|
|
107
|
+
const prev=requests.get(key);
|
|
108
|
+
// Multiple records for the same message ID are snapshots, not separate requests.
|
|
109
|
+
// Preserve the highest observed counters, including streaming partial updates.
|
|
110
|
+
if(prev) {
|
|
111
|
+
for(const k of ['input','output','cacheWrite','cacheRead','cacheWrite5m','cacheWrite1h']) prev[k]=Math.max(prev[k],m[k]);
|
|
112
|
+
if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
|
|
113
|
+
} else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
|
|
114
|
+
});
|
|
115
|
+
return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cache derived metadata and token counters only. Never persist transcript text beyond
|
|
119
|
+
// the short session title. Cache invalidates when file size/mtime changes.
|
|
120
|
+
const CACHE_VERSION=3;
|
|
121
|
+
const cacheDir=()=>path.join(os.homedir(),'.claude-usage-explorer','cache-v3');
|
|
122
|
+
function readCachedFile(file){
|
|
123
|
+
const st=fs.statSync(file);
|
|
124
|
+
const key=Buffer.from(file).toString('hex');
|
|
125
|
+
// A filename derived from SHA-256 avoids excessively long paths.
|
|
126
|
+
const name=hashPath(file)+'.json';
|
|
127
|
+
const dest=path.join(cacheDir(),name);
|
|
128
|
+
try {const obj=JSON.parse(fs.readFileSync(dest,'utf8'));
|
|
129
|
+
if(obj.version===CACHE_VERSION && obj.size===st.size && obj.mtime===st.mtimeMs)return obj.parsed;
|
|
130
|
+
} catch {}
|
|
131
|
+
const parsed=parseFile(file);
|
|
132
|
+
try {fs.mkdirSync(cacheDir(),{recursive:true,mode:0o700});
|
|
133
|
+
const tmp=dest+'.'+process.pid+'.tmp';
|
|
134
|
+
fs.writeFileSync(tmp,JSON.stringify({version:CACHE_VERSION,size:st.size,mtime:st.mtimeMs,parsed}),{mode:0o600});
|
|
135
|
+
fs.renameSync(tmp,dest);
|
|
136
|
+
}catch{};
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
function hashPath(s){return createHash('sha256').update(s).digest('hex');}
|
|
140
|
+
|
|
141
|
+
export function ingest(roots,rates=DEFAULT_RATES) {
|
|
142
|
+
const files=[...new Set(roots.flatMap(r=>walk(r)))];const sessions=new Map();let malformed=0;
|
|
143
|
+
for(const file of files) {
|
|
144
|
+
let parsed;try {parsed=readCachedFile(file);} catch {malformed++;continue;}
|
|
145
|
+
malformed+=parsed.bad;
|
|
146
|
+
// Distinct files are attributed by session and agent, not only by filename.
|
|
147
|
+
const key=parsed.sessionId;
|
|
148
|
+
let s=sessions.get(key);
|
|
149
|
+
if(!s) {s={id:key,title:null,cwd:null,start:null,end:null,agents:new Map(),files:[]};sessions.set(key,s);}
|
|
150
|
+
s.files.push(file);s.title=s.title||parsed.title;s.cwd=s.cwd||parsed.cwd;
|
|
151
|
+
if(parsed.start!==null) s.start=Math.min(s.start??parsed.start,parsed.start);
|
|
152
|
+
if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
|
|
153
|
+
let agent=s.agents.get(parsed.agent);
|
|
154
|
+
if(!agent) {agent={id:parsed.agent,requests:[]};s.agents.set(parsed.agent,agent);}
|
|
155
|
+
agent.requests.push(...parsed.requests);
|
|
156
|
+
}
|
|
157
|
+
return {sessions:[...sessions.values()],files:files.length,malformed,roots};
|
|
158
|
+
}
|
|
159
|
+
function zonedBoundary(year,month,dayOfMonth,hour,minute,zone){
|
|
160
|
+
const last=new Date(Date.UTC(year,month+1,0)).getUTCDate();
|
|
161
|
+
const target=Date.UTC(year,month,Math.min(dayOfMonth,last),hour,minute);
|
|
162
|
+
const fmt=new Intl.DateTimeFormat('en-US',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'});
|
|
163
|
+
let candidate=target;
|
|
164
|
+
for(let i=0;i<4;i++){
|
|
165
|
+
const parts=Object.fromEntries(fmt.formatToParts(candidate).filter(p=>p.type!=='literal').map(p=>[p.type,Number(p.value)]));
|
|
166
|
+
const represented=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute);
|
|
167
|
+
const diff=target-represented;
|
|
168
|
+
candidate+=diff;
|
|
169
|
+
if(!diff)break;
|
|
170
|
+
}
|
|
171
|
+
return candidate;
|
|
172
|
+
}
|
|
173
|
+
export function timeWindow(value,now=Date.now(),billingDay=null,billingTimeZone='UTC',billingHour=0,billingMinute=0) {
|
|
174
|
+
if(!value||value==='all') return [-Infinity,Infinity];
|
|
175
|
+
const d=new Date(now);const utcStart=Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),d.getUTCDate());
|
|
176
|
+
if(value==='today')return [utcStart,utcStart+day];
|
|
177
|
+
if(value==='yesterday')return [utcStart-day,utcStart];
|
|
178
|
+
if(value==='7d')return [now-7*day,now];
|
|
179
|
+
if(value==='30d')return [now-30*day,now];
|
|
180
|
+
if(value==='month')return [Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),1),Date.UTC(d.getUTCFullYear(),d.getUTCMonth()+1,1)];
|
|
181
|
+
if(value==='previous-month')return [Date.UTC(d.getUTCFullYear(),d.getUTCMonth()-1,1),Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),1)];
|
|
182
|
+
if(value==='cycle'||value==='previous-cycle') {
|
|
183
|
+
if(!Number.isInteger(billingDay)||billingDay<1||billingDay>31) throw new Error('Billing cycle requires --billing-day 1..31 (UTC-based approximation)');
|
|
184
|
+
const zone=billingTimeZone||'UTC';
|
|
185
|
+
const local=new Intl.DateTimeFormat('en-US',{timeZone:zone,year:'numeric',month:'numeric'}).formatToParts(now);
|
|
186
|
+
const parts=Object.fromEntries(local.filter(p=>p.type==='year'||p.type==='month').map(p=>[p.type,Number(p.value)]));
|
|
187
|
+
let y=parts.year,m=parts.month-1;
|
|
188
|
+
const boundary=(yy,mm)=>zonedBoundary(yy,mm,billingDay,billingHour,billingMinute,zone);
|
|
189
|
+
let current=boundary(y,m);
|
|
190
|
+
if(now<current){m--;current=boundary(y,m);}
|
|
191
|
+
if(value==='cycle')return [current,boundary(y,m+1)];
|
|
192
|
+
return [boundary(y,m-1),current];
|
|
193
|
+
}
|
|
194
|
+
const match=/^(\d{4}-\d\d-\d\d):(\d{4}-\d\d-\d\d)$/.exec(value);
|
|
195
|
+
if(match){const a=Date.parse(match[1]+'T00:00:00Z'),b=Date.parse(match[2]+'T00:00:00Z');if(!Number.isFinite(a)||!Number.isFinite(b)||a>b)throw new Error('Invalid date range');return [a,b+day];}
|
|
196
|
+
throw new Error('Invalid --period. Use all,today,yesterday,7d,30d,month,previous-month,cycle,previous-cycle,YYYY-MM-DD:YYYY-MM-DD');
|
|
197
|
+
}
|
|
198
|
+
export function summarize(data,window=[-Infinity,Infinity],rates=DEFAULT_RATES) {
|
|
199
|
+
const projects=new Map(), sessions=[];const models=new Map();const overall=ZERO();
|
|
200
|
+
for(const s of data.sessions){
|
|
201
|
+
const stat=ZERO(),agents=[];
|
|
202
|
+
for(const a of s.agents.values()){
|
|
203
|
+
const am=ZERO();const byModel=new Map();
|
|
204
|
+
for(const r of a.requests){if(r.timestamp===null||r.timestamp<window[0]||r.timestamp>=window[1])continue;
|
|
205
|
+
const one=ZERO();Object.assign(one,{input:r.input,output:r.output,cacheWrite:r.cacheWrite,cacheRead:r.cacheRead,requests:1});
|
|
206
|
+
const cost=costFor(r.model,r,rates);if(cost===null)one.unknownCostRequests=1;else one.knownCost=cost;
|
|
207
|
+
add(am,one);add(overall,one);
|
|
208
|
+
if(!byModel.has(r.model))byModel.set(r.model,ZERO());add(byModel.get(r.model),one);
|
|
209
|
+
if(!models.has(r.model))models.set(r.model,ZERO());add(models.get(r.model),one);
|
|
210
|
+
}
|
|
211
|
+
if(am.requests){add(stat,am);agents.push({id:a.id,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
|
|
212
|
+
}
|
|
213
|
+
if(!stat.requests)continue;
|
|
214
|
+
const project=s.cwd||'(unknown project)';
|
|
215
|
+
if(!projects.has(project))projects.set(project,{name:project,stats:ZERO(),sessions:0,models:new Map()});
|
|
216
|
+
const pr=projects.get(project);add(pr.stats,stat);pr.sessions++;
|
|
217
|
+
for(const a of agents)for(const m of a.models){if(!pr.models.has(m.name))pr.models.set(m.name,ZERO());add(pr.models.get(m.name),m.stats);}
|
|
218
|
+
const sm=new Map();for(const a of agents)for(const m of a.models){if(!sm.has(m.name))sm.set(m.name,ZERO());add(sm.get(m.name),m.stats);}
|
|
219
|
+
sessions.push({id:s.id,title:s.title||s.id,project,start:s.start,end:s.end,stats:stat,agents,models:[...sm].map(([name,stats])=>({name,stats}))});
|
|
220
|
+
}
|
|
221
|
+
return {overall,projects:[...projects.values()].map(p=>({...p,models:[...p.models].map(([name,stats])=>({name,stats}))})),sessions,models:[...models].map(([name,stats])=>({name,stats})),files:data.files,malformed:data.malformed,roots:data.roots};
|
|
222
|
+
}
|
|
223
|
+
export function selectCurrentProject(report,cwd) {
|
|
224
|
+
const normalized=p=>path.resolve(p).toLowerCase();
|
|
225
|
+
const exact=report.projects.filter(p=>p.name!=='(unknown project)' && normalized(p.name)===normalized(cwd));
|
|
226
|
+
return exact;
|
|
227
|
+
}
|
|
228
|
+
export function selectCurrentSession(report,sessionId) {
|
|
229
|
+
if(!sessionId) return [];
|
|
230
|
+
return report.sessions.filter(s=>s.id===sessionId);
|
|
231
|
+
}
|
|
232
|
+
function table(headers,rows){
|
|
233
|
+
const widths=headers.map((h,i)=>Math.min(62,Math.max(h.length,...rows.map(r=>String(r[i]??'').length))));
|
|
234
|
+
const line=r=>'| '+r.map((v,i)=>String(v??'').slice(0,widths[i]).padEnd(widths[i])).join(' | ')+' |';
|
|
235
|
+
console.log(line(headers));console.log('| '+widths.map(w=>'-'.repeat(w)).join(' | ')+' |');
|
|
236
|
+
for(const r of rows)console.log(line(r));
|
|
237
|
+
if(!rows.length)console.log('(no matching usage)');
|
|
238
|
+
}
|
|
239
|
+
function formatStats(s){return `${number(totalTokens(s))} tokens | ${number(s.input)} in / ${number(s.output)} out / ${number(s.cacheWrite)} cache-write / ${number(s.cacheRead)} cache-read | ${money(s.knownCost)} estimated${s.unknownCostRequests?' + unknown pricing ('+s.unknownCostRequests+')':''}`;}
|
|
240
|
+
function rank(items){return items.sort((a,b)=>b.stats.knownCost-a.stats.knownCost || totalTokens(b.stats)-totalTokens(a.stats));}
|
|
241
|
+
function show(title,items,limit=25){console.log('\n'+title);for(const [i,x] of rank(items).slice(0,limit).entries())console.log(`${String(i+1).padStart(2)}. ${x.name||x.title} — ${formatStats(x.stats)}`);if(!items.length)console.log(' No matching usage.');}
|
|
242
|
+
export async function main(argv){
|
|
243
|
+
const command=argv[0]||'overview';const opts={};
|
|
244
|
+
for(let i=1;i<argv.length;i++){const v=argv[i];if(v.startsWith('--')){const [key,inline]=v.slice(2).split('=',2);opts[key]=inline??(argv[i+1]&&!argv[i+1].startsWith('--')?argv[++i]:true);}}
|
|
245
|
+
if(['help','--help','-h'].includes(command)){console.log(`Claude Usage Explorer v0.1.0\n\nCommands: overview | projects | sessions | models | high | diagnostics | current-project | current-session\nOptions: --root PATH --period all|today|yesterday|7d|30d|month|previous-month|cycle|previous-cycle|YYYY-MM-DD:YYYY-MM-DD\n --billing-day 1..31 --limit N --json --rates PATH\n\nExamples:\n claude-usage overview --period 7d\n claude-usage sessions --period cycle --billing-day 15\n claude-usage projects --json`);return;}
|
|
246
|
+
if(!['overview','projects','sessions','models','high','diagnostics','current-project','current-session'].includes(command))throw new Error('Unknown command: '+command);
|
|
247
|
+
let rates=DEFAULT_RATES;
|
|
248
|
+
if(opts.rates){const custom=JSON.parse(fs.readFileSync(path.resolve(opts.rates),'utf8'));rates={...rates,...custom};}
|
|
249
|
+
const roots=opts.root?[path.resolve(opts.root)]:projectRoots();
|
|
250
|
+
const data=ingest(roots,rates),window=timeWindow(opts.period||'all',Date.now(),opts['billing-day']?Number(opts['billing-day']):null);
|
|
251
|
+
const report=summarize(data,window,rates);const limit=Math.max(1,Math.min(500,Number(opts.limit)||25));
|
|
252
|
+
if(command==='current-project') {
|
|
253
|
+
const cwd=path.resolve(String(opts.cwd||process.cwd()));
|
|
254
|
+
const selected=selectCurrentProject(report,cwd);
|
|
255
|
+
if(opts.json){console.log(JSON.stringify({cwd,projects:selected,sessions:report.sessions.filter(s=>selected.some(p=>p.name===s.project))},null,2));return;}
|
|
256
|
+
console.log('Current project: '+cwd);
|
|
257
|
+
table(['Project','Tokens','Input','Output','Cache write','Cache read','Est. USD'],selected.map(p=>[path.basename(p.name),number(totalTokens(p.stats)),number(p.stats.input),number(p.stats.output),number(p.stats.cacheWrite),number(p.stats.cacheRead),money(p.stats.knownCost)]));
|
|
258
|
+
if(!selected.length)console.log('No usage found for this exact project path. Use --cwd PATH if needed.');
|
|
259
|
+
else table(['Session','Tokens','Est. USD'],rank(report.sessions.filter(s=>s.project===selected[0].name)).slice(0,limit).map(s=>[s.title,number(totalTokens(s.stats)),money(s.stats.knownCost)]));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if(command==='current-session') {
|
|
263
|
+
const id=String(opts['session-id']||process.env.CLAUDE_SESSION_ID||'');
|
|
264
|
+
if(!id) {console.log('Current session ID is unavailable. Supply --session-id UUID (do not guess from latest activity).');return;}
|
|
265
|
+
const selected=selectCurrentSession(report,id);
|
|
266
|
+
if(opts.json){console.log(JSON.stringify({sessionId:id,sessions:selected},null,2));return;}
|
|
267
|
+
console.log('Current session: '+id);
|
|
268
|
+
table(['Session','Tokens','Input','Output','Cache write','Cache read','Est. USD'],selected.map(s=>[s.title,number(totalTokens(s.stats)),number(s.stats.input),number(s.stats.output),number(s.stats.cacheWrite),number(s.stats.cacheRead),money(s.stats.knownCost)]));
|
|
269
|
+
for(const s of selected){table(['Agent','Tokens','Est. USD'],s.agents.map(a=>[a.id,number(totalTokens(a.stats)),money(a.stats.knownCost)]));}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if(opts.json){console.log(JSON.stringify({...report,period:opts.period||'all'},null,2));return;}
|
|
273
|
+
console.log('Claude Usage Explorer | '+(opts.period||'all')+' | estimated API-equivalent costs (USD)');
|
|
274
|
+
if(command==='diagnostics'){console.log('Roots:',roots.join(', '));console.log('JSONL files:',data.files,'Malformed records/files:',data.malformed,'Sessions:',data.sessions.length);console.log('All source data is read-only. Unknown models have no price.');return;}
|
|
275
|
+
if(command==='overview'){console.log('\nTotal: '+formatStats(report.overall));show('Top projects',report.projects,Math.min(10,limit));show('Top models',report.models,Math.min(10,limit));show('Top sessions',report.sessions,Math.min(10,limit));}
|
|
276
|
+
if(command==='projects')table(['Project','Sessions','Tokens','Est. USD'],rank(report.projects).slice(0,limit).map(p=>[p.name,p.sessions,number(totalTokens(p.stats)),money(p.stats.knownCost)]));
|
|
277
|
+
if(command==='sessions')table(['Session','Project','Tokens','Est. USD'],rank(report.sessions).slice(0,limit).map(s=>[s.title,path.basename(s.project),number(totalTokens(s.stats)),money(s.stats.knownCost)]));
|
|
278
|
+
if(command==='models')table(['Model','Requests','Input','Output','Cache write','Cache read','Tokens','Est. USD'],rank(report.models).slice(0,limit).map(m=>[m.name,m.stats.requests,number(m.stats.input),number(m.stats.output),number(m.stats.cacheWrite),number(m.stats.cacheRead),number(totalTokens(m.stats)),money(m.stats.knownCost)]));
|
|
279
|
+
if(command==='high'){
|
|
280
|
+
const sorted=rank(report.sessions);show('Highest-consumption sessions',sorted,limit);
|
|
281
|
+
console.log('\nNote: ranked by known estimated cost, then tokens; statistical percentile classification is planned.');
|
|
282
|
+
}
|
|
283
|
+
if(data.malformed)console.log(`\nWarning: ${data.malformed} malformed JSONL records or unreadable files skipped.`);
|
|
284
|
+
if(report.overall.unknownCostRequests)console.log('Warning: some models lack pricing; cost rankings may be incomplete.');
|
|
285
|
+
}
|
package/src/launcher.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const cliPath=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../bin/claude-usage.mjs');
|
|
6
|
+
const shQuote=s=>`'${String(s).replaceAll("'", "'\\''")}'`;
|
|
7
|
+
const appleQuote=s=>`"${String(s).replaceAll('\\','\\\\').replaceAll('"','\\"')}"`;
|
|
8
|
+
|
|
9
|
+
/** Opens a real interactive TTY beside Claude; cannot embed a TUI in Claude's tool output. */
|
|
10
|
+
export function launchSidecar({cwd=process.cwd(),platform=process.platform,spawnProcess=spawn,probe=spawnSync}={}){
|
|
11
|
+
const command=[shQuote(process.execPath),shQuote(cliPath),'tui','--cwd',shQuote(cwd)].join(' ');
|
|
12
|
+
let program,args;
|
|
13
|
+
if(platform==='darwin'){
|
|
14
|
+
program='osascript';args=['-e',`tell application "Terminal" to do script ${appleQuote(command)}`,'-e','tell application "Terminal" to activate'];
|
|
15
|
+
}else if(platform==='win32'){
|
|
16
|
+
// Pass arguments as a PowerShell literal array; do not interpolate a shell command.
|
|
17
|
+
const psQuote=s=>`'${String(s).replaceAll("'","''")}'`;
|
|
18
|
+
const ps=`Start-Process -FilePath ${psQuote('cmd.exe')} -ArgumentList @('/k', ${psQuote('"'+process.execPath+'" "'+cliPath+'" tui --cwd "'+cwd+'"')})`;
|
|
19
|
+
program='powershell.exe';args=['-NoProfile','-NonInteractive','-Command',ps];
|
|
20
|
+
}else{
|
|
21
|
+
const choices=[['x-terminal-emulator',['-e','sh','-c',command]],['gnome-terminal',['--','sh','-c',command]],['konsole',['-e','sh','-c',command]],['xterm',['-e','sh','-c',command]]];
|
|
22
|
+
const chosen=choices.find(([name])=>probe('which',[name],{stdio:'ignore'}).status===0);
|
|
23
|
+
if(!chosen)throw new Error('No supported terminal emulator found. Run `claude-usage tui` in another terminal.');
|
|
24
|
+
[program,args]=chosen;
|
|
25
|
+
}
|
|
26
|
+
const child=spawnProcess(program,args,{detached:true,stdio:'ignore',windowsHide:false});
|
|
27
|
+
child.on?.('error',err=>console.error('Unable to launch terminal:',err.message));
|
|
28
|
+
child.unref?.();
|
|
29
|
+
return {program,args};
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|
package/src/snapshot.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {DEFAULT_RATES,ingest,projectRoots,summarize,timeWindow} from './core.mjs';
|
|
5
|
+
import {describePeriod} from './tui.mjs';
|
|
6
|
+
import {readPlanUsage} from './plan-usage.mjs';
|
|
7
|
+
|
|
8
|
+
export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projectRoots()}={}) {
|
|
9
|
+
const periodIndex=argv.indexOf('--period');
|
|
10
|
+
const period=periodIndex>=0 ? argv[periodIndex+1] : 'all';
|
|
11
|
+
const settingsPath=path.join(home,'.claude-usage-explorer','settings.json');
|
|
12
|
+
let settings={};
|
|
13
|
+
try {settings=JSON.parse(fs.readFileSync(settingsPath,'utf8'));}catch{}
|
|
14
|
+
const rates={...DEFAULT_RATES,...settings.rates};
|
|
15
|
+
const window=timeWindow(period,now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
|
|
16
|
+
const report=summarize(ingest(roots,rates),window,rates);
|
|
17
|
+
return {period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report,planUsage:readPlanUsage({now})};
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {ingest,projectRoots,summarize,timeWindow,totalTokens,DEFAULT_RATES} from './core.mjs';
|
|
6
|
+
|
|
7
|
+
const HOME=()=>path.join(os.homedir(),'.claude-usage-explorer');
|
|
8
|
+
const SUMMARY=()=>path.join(HOME(),'status-summary.json');
|
|
9
|
+
const SETTINGS=()=>path.join(HOME(),'settings.json');
|
|
10
|
+
const MAX_AGE=60_000;
|
|
11
|
+
const readJson=p=>{try{return JSON.parse(fs.readFileSync(p,'utf8'));}catch{return {};}};
|
|
12
|
+
const usd=n=>'$'+Number(n).toFixed(2);
|
|
13
|
+
const compact=n=>new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n);
|
|
14
|
+
export function formatStatusLine(input,summary={}) {
|
|
15
|
+
const cost=Number(input?.cost?.total_cost_usd);
|
|
16
|
+
const session=Number.isFinite(cost)&&cost>=0?usd(cost):'—';
|
|
17
|
+
const model=input?.model?.display_name||input?.model?.id||'Claude';
|
|
18
|
+
const cycle=summary.cycle;
|
|
19
|
+
const cycleText=cycle ? (cycle.unknownCostRequests ? `${usd(cycle.knownCost)}+` : usd(cycle.knownCost)) : '—';
|
|
20
|
+
const tokens=cycle?compact(totalTokens(cycle)):'—';
|
|
21
|
+
const status=summary.error?' (history unavailable)':'';
|
|
22
|
+
return `${model} │ Session ${session} │ Cycle ${cycleText} est. · ${tokens} tokens${status}`;
|
|
23
|
+
}
|
|
24
|
+
export function computeSummary(now=Date.now(),settings=readJson(SETTINGS())){
|
|
25
|
+
if(!Number.isInteger(settings.billingDay)||settings.billingDay<1||settings.billingDay>31) return {error:'Set billing date in TUI (B → Billing)'};
|
|
26
|
+
const window=timeWindow('cycle',now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
|
|
27
|
+
const report=summarize(ingest(projectRoots()),window,{...DEFAULT_RATES,...(settings.rates||{})});
|
|
28
|
+
return {cycle:report.overall,window,computedAt:now};
|
|
29
|
+
}
|
|
30
|
+
function cachedSummary(now=Date.now()){
|
|
31
|
+
const existing=readJson(SUMMARY());
|
|
32
|
+
const settings=readJson(SETTINGS());
|
|
33
|
+
let stat;
|
|
34
|
+
try{stat=fs.statSync(SETTINGS());}catch{}
|
|
35
|
+
const settingsMtime=stat?.mtimeMs||0;
|
|
36
|
+
if(existing.computedAt && now-existing.computedAt<MAX_AGE && existing.settingsMtime===settingsMtime) return existing;
|
|
37
|
+
let result;
|
|
38
|
+
try{result=computeSummary(now,settings);}catch(e){result={error:e.message};}
|
|
39
|
+
result.computedAt=now;result.settingsMtime=settingsMtime;
|
|
40
|
+
try{fs.mkdirSync(HOME(),{recursive:true,mode:0o700});fs.writeFileSync(SUMMARY(),JSON.stringify(result),{mode:0o600});}catch{}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
export async function runStatusLine(){
|
|
44
|
+
// Claude Code passes a JSON status payload via stdin. Never log it or store it.
|
|
45
|
+
let raw='';for await(const chunk of process.stdin){raw+=chunk;if(raw.length>2_000_000)break;}
|
|
46
|
+
let input={};try{input=JSON.parse(raw);}catch{}
|
|
47
|
+
process.stdout.write(formatStatusLine(input,cachedSummary())+'\n');
|
|
48
|
+
}
|
|
49
|
+
export function installStatusLine({settingsPath=path.join(os.homedir(),'.claude','settings.json'),force=false,scriptPath=fileURLToPath(new URL('../bin/claude-usage.mjs',import.meta.url))}={}){
|
|
50
|
+
const settings=readJson(settingsPath);
|
|
51
|
+
if(settings.statusLine && !force) throw new Error('Claude already has a statusLine configured. To replace it, run statusline-install --force (a backup will be saved).');
|
|
52
|
+
fs.mkdirSync(path.dirname(settingsPath),{recursive:true});
|
|
53
|
+
if(fs.existsSync(settingsPath)) fs.copyFileSync(settingsPath,settingsPath+'.claude-usage-backup-'+Date.now());
|
|
54
|
+
// Node's executable resolved during install may be an asdf shim; prefer the current absolute Node executable.
|
|
55
|
+
const quote=s=>`"${String(s).replace(/"/g,'\\"')}"`;
|
|
56
|
+
settings.statusLine={type:'command',command:`${quote(process.execPath)} ${quote(scriptPath)} statusline`,padding:0};
|
|
57
|
+
fs.writeFileSync(settingsPath,JSON.stringify(settings,null,2)+'\n',{mode:0o600});
|
|
58
|
+
return settingsPath;
|
|
59
|
+
}
|
package/src/tui.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
|
|
6
|
+
import {readPlanUsage} from './plan-usage.mjs';
|
|
7
|
+
|
|
8
|
+
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
|
+
export function describePeriod(period, settings={}, now=Date.now()){
|
|
10
|
+
const base=periods.find(([,id])=>id===period)?.[0]||period;
|
|
11
|
+
if(!['cycle','previous-cycle'].includes(period))return base;
|
|
12
|
+
if(!Number.isInteger(settings.billingDay)||settings.billingDay<1||settings.billingDay>31)return base+' · Set renewal date';
|
|
13
|
+
try{
|
|
14
|
+
const [start,end]=timeWindow(period,now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
|
|
15
|
+
const format=new Intl.DateTimeFormat('en-US',{timeZone:settings.billingTimeZone||'UTC',month:'short',day:'numeric',year:'numeric'});
|
|
16
|
+
return `${base} · ${format.format(start)} – ${format.format(end-1)}`;
|
|
17
|
+
}catch{return base+' · Invalid billing settings';}
|
|
18
|
+
}
|
|
19
|
+
const fmt=n=>number(n||0);
|
|
20
|
+
const truncate=(s,n)=>{s=String(s??'');return s.length>n?s.slice(0,Math.max(0,n-1))+'…':s;};
|
|
21
|
+
const money=s=>s.unknownCostRequests?`${s.knownCost?'$'+s.knownCost.toFixed(2)+' + ':''}N/A`:`$${s.knownCost.toFixed(2)}`;
|
|
22
|
+
const settingFile=()=>path.join(os.homedir(),'.claude-usage-explorer','settings.json');
|
|
23
|
+
function readSettings(){try{return JSON.parse(fs.readFileSync(settingFile(),'utf8'));}catch{return {};}}
|
|
24
|
+
function saveSettings(s){fs.mkdirSync(path.dirname(settingFile()),{recursive:true,mode:0o700});fs.writeFileSync(settingFile(),JSON.stringify(s,null,2),{mode:0o600});}
|
|
25
|
+
const sortColumns=[['cost','Est. USD'],['tokens','Tokens'],['requests','Requests'],['input','Input'],['output','Output'],['cacheWrite','Cache write'],['cacheRead','Cache read'],['name','Name']];
|
|
26
|
+
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
|
+
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};
|
|
30
|
+
let planUsage=null,planCheckedAt=0;
|
|
31
|
+
function refreshPlan(now=Date.now()){if(now-planCheckedAt<4000)return;planCheckedAt=now;try{planUsage=planReader({now})||null;}catch{planUsage=null;}}
|
|
32
|
+
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
|
+
const rates=()=>({...DEFAULT_RATES,...settings.rates});
|
|
34
|
+
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());}}
|
|
35
|
+
refresh();
|
|
36
|
+
function rows(){let list=[];
|
|
37
|
+
if(view==='projects')list=report.projects.map(x=>({...x,label:path.basename(x.name)||x.name}));
|
|
38
|
+
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==='models')list=(agent?.models||session?.models||project?.models||report.models).map(x=>({...x,label:x.name}));
|
|
41
|
+
if(view==='periods')list=periods.map(([,v])=>({label:describePeriod(v,settings),value:v,stats:empty}));
|
|
42
|
+
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}];
|
|
43
|
+
if(view==='sort')list=sortColumns.map(([value,label])=>({value,label,stats:empty}));
|
|
44
|
+
if(filter&&!['periods','settings','sort'].includes(view))list=list.filter(x=>x.label.toLowerCase().includes(filter.toLowerCase()));
|
|
45
|
+
if(!['periods','settings','sort'].includes(view))list.sort((a,b)=>{const av=value(a,sort),bv=value(b,sort);if(av===null&&bv!==null)return 1;if(bv===null&&av!==null)return -1;let v=typeof av==='string'?av.localeCompare(bv):av-bv;return descending?-v:v;});
|
|
46
|
+
return list;
|
|
47
|
+
}
|
|
48
|
+
function nav(next){stack.push({view,selection,offset,project,session,agent,filter});view=next;selection=0;offset=0;filter='';}
|
|
49
|
+
function restore(){if(!stack.length){switchView('projects');return;}const p=stack.pop();view=p.view;selection=p.selection;offset=p.offset;project=p.project;session=p.session;agent=p.agent;filter=p.filter;}
|
|
50
|
+
function switchView(next){view=next;selection=0;offset=0;stack=[];filter='';project=null;session=null;agent=null;}
|
|
51
|
+
function open(){const x=rows()[selection];if(!x)return null;
|
|
52
|
+
if(view==='projects'){project=x;nav('sessions');}
|
|
53
|
+
else if(view==='sessions'){session=x;nav('agents');}
|
|
54
|
+
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==='settings')return x.value;
|
|
57
|
+
else if(view==='sort'){setSort(x.value);restore();}
|
|
58
|
+
else if(view==='models')message=`${x.name} · ${fmt(totalTokens(x.stats))} tokens · ${money(x.stats)} estimated USD`;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function rescan(roots,{automatic=false}={}){
|
|
62
|
+
const selectedProject=project?.name,selectedSession=session?.id,selectedAgent=agent?.id;
|
|
63
|
+
const selectedRow=rows()[selection];const selectedId=selectedRow?.id??selectedRow?.name??selectedRow?.value;
|
|
64
|
+
sourceData=ingest(roots);refresh();
|
|
65
|
+
project=report.projects.find(p=>p.name===selectedProject)||null;
|
|
66
|
+
session=report.sessions.find(s=>s.id===selectedSession&&(!project||s.project===project.name))||null;
|
|
67
|
+
agent=session?.agents.find(a=>a.id===selectedAgent)||null;
|
|
68
|
+
if(view==='sessions'&&!project)view='projects';
|
|
69
|
+
if(view==='agents'&&!session)view=project?'sessions':'projects';
|
|
70
|
+
if(view==='models'&&!agent&&!session&&!project){/* all-model scope is valid */}
|
|
71
|
+
const updatedRows=rows();const index=updatedRows.findIndex(r=>(r.id??r.name??r.value)===selectedId);
|
|
72
|
+
selection=index>=0?index:Math.min(selection,Math.max(0,updatedRows.length-1));
|
|
73
|
+
liveState.lastUpdated=Date.now();liveState.error=null;
|
|
74
|
+
if(!automatic)message='History refreshed.';
|
|
75
|
+
}
|
|
76
|
+
function setLiveState(update){liveState={...liveState,...update};}
|
|
77
|
+
function toggleLive(){liveState.enabled=!liveState.enabled;liveState.lastCheck=Date.now();}
|
|
78
|
+
function setPeriod(p){currentPeriod=p;refresh();switchView('projects');}
|
|
79
|
+
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
|
+
function setModelRate(model,values){settings.rates??={};settings.rates[model]=values;saveSettings(settings);refresh();message=`Saved custom USD/million-token rates for ${model}.`;}
|
|
81
|
+
function setFilter(s){filter=s;selection=0;offset=0;}
|
|
82
|
+
function setSort(key){if(sort===key)descending=!descending;else{sort=key;descending=key!=='name';}selection=0;offset=0;}
|
|
83
|
+
function render(width=process.stdout.columns||110,height=process.stdout.rows||30){
|
|
84
|
+
refreshPlan();
|
|
85
|
+
width=Math.max(20,width);height=Math.max(8,height);
|
|
86
|
+
const tty=process.stdout.isTTY && !process.env.NO_COLOR;
|
|
87
|
+
const c=(text,style)=>tty?`\x1b[${style}m${text}\x1b[0m`:text;
|
|
88
|
+
const line=(str='')=>out.push(clipAnsi(str,Math.max(1,width-1)));
|
|
89
|
+
const rule=()=>line(c('─'.repeat(width),'90'));
|
|
90
|
+
const out=[];
|
|
91
|
+
const pad=(str,n,align='left')=>{str=truncate(str,n);return align==='right'?str.padStart(n):str.padEnd(n);};
|
|
92
|
+
const title=['PROJECTS','SESSIONS','AGENTS','MODELS','DATES','SETTINGS','SORT'][['projects','sessions','agents','models','periods','settings','sort'].indexOf(view)]||view.toUpperCase();
|
|
93
|
+
line(c(' ◈ CLAUDE USAGE EXPLORER','1;36')+c(` ${title} LOCAL • NO AI CALLS`,'90'));
|
|
94
|
+
rule();
|
|
95
|
+
const crumb=['Projects',project&&path.basename(project.name),session&&truncate(session.title,32),agent&&agent.label].filter(Boolean).join(' › ');
|
|
96
|
+
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';
|
|
99
|
+
line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
|
|
100
|
+
rule();
|
|
101
|
+
const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
|
|
102
|
+
const selected=available[selection];
|
|
103
|
+
const breakdown=view==='models'?null:selected?.models|| (view==='projects'?report.models:view==='sessions'?project?.models:view==='agents'?session?.models:agent?.models);
|
|
104
|
+
// Fixed-height table, model panel, and footer prevent vertical jumping on selection.
|
|
105
|
+
const modelPanelHeight=7; // heading + five model rows + divider
|
|
106
|
+
const footerHeight=5; // separator, two shortcuts, search, status
|
|
107
|
+
const headerHeight=7;
|
|
108
|
+
const visible=Math.max(0,height-headerHeight-modelPanelHeight-footerHeight-2);
|
|
109
|
+
if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
|
|
110
|
+
// Keep columns aligned by terminal width, shrinking the name first.
|
|
111
|
+
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));
|
|
113
|
+
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'));
|
|
115
|
+
}
|
|
116
|
+
for(let i=offset;i<Math.min(available.length,offset+visible);i++){
|
|
117
|
+
const x=available[i],active=i===selection;
|
|
118
|
+
let text;
|
|
119
|
+
if(['periods','settings','sort'].includes(view))text=` ${active?'❯':' '} ${x.label}${view==='periods'&&x.value===currentPeriod?' ✓':''}`;
|
|
120
|
+
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(' ');
|
|
122
|
+
}
|
|
123
|
+
line(active?c(truncate(text,width),'7'):truncate(text,width));
|
|
124
|
+
}
|
|
125
|
+
if(!available.length)line(c(' No matching records. Press / to clear your search.','33'));
|
|
126
|
+
// Fill the table viewport so the model panel and help never move.
|
|
127
|
+
const renderedRows=Math.min(available.length-offset,visible);
|
|
128
|
+
for(let i=renderedRows;i<visible;i++)line('');
|
|
129
|
+
line(c(` ${available.length?`${offset+1}–${Math.min(available.length,offset+visible)} of ${available.length}`:'0 rows'} • PgUp/PgDn scroll`,'90'));
|
|
130
|
+
rule();
|
|
131
|
+
line(c(' MODEL BREAKDOWN · SELECTED ROW','1;36')+c(' (M: full model table)','90'));
|
|
132
|
+
const models=breakdown?[...breakdown].sort((a,b)=>totalTokens(b.stats)-totalTokens(a.stats)).slice(0,5):[];
|
|
133
|
+
for(let i=0;i<5;i++){
|
|
134
|
+
const m=models[i];
|
|
135
|
+
line(m?` ${pad(m.name,30)} ${pad(fmt(totalTokens(m.stats)),16,'right')} tokens ${pad(money(m.stats),14,'right')}`:'');
|
|
136
|
+
}
|
|
137
|
+
rule();
|
|
138
|
+
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'));
|
|
140
|
+
line(c(' '+(filter?'Search: '+truncate(filter,width-12):' '),'36'));
|
|
141
|
+
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
|
+
return out.slice(0,Math.max(1,height-1)).join('\n');
|
|
143
|
+
}
|
|
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;}};
|
|
145
|
+
}
|
|
146
|
+
// Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
|
|
147
|
+
export function clipAnsi(value, maxWidth) {
|
|
148
|
+
const tokens=String(value).match(/\x1b\[[0-9;]*m|[^\x1b]+/g)||[];
|
|
149
|
+
let output='', count=0, styled=false;
|
|
150
|
+
for(const token of tokens){
|
|
151
|
+
if(token.startsWith('\x1b[')){output+=token;styled=token!=='\x1b[0m';continue;}
|
|
152
|
+
for(const char of token){
|
|
153
|
+
const size=char.codePointAt(0)>0xffff?2:1;
|
|
154
|
+
if(count+size>maxWidth){if(styled)output+='\x1b[0m';return output;}
|
|
155
|
+
output+=char;count+=size;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return output;
|
|
159
|
+
}
|
|
160
|
+
// Update only changed terminal rows. Never clear the entire screen for navigation.
|
|
161
|
+
export function screenDiff(previous, next, {force=false}={}) {
|
|
162
|
+
const old=previous===null?[]:previous.split('\n');
|
|
163
|
+
const current=next.split('\n');
|
|
164
|
+
let output='';
|
|
165
|
+
for(let i=0;i<Math.max(old.length,current.length);i++){
|
|
166
|
+
if(!force && old[i]===current[i])continue;
|
|
167
|
+
output+=`\x1b[${i+1};1H\x1b[2K${current[i]??''}`;
|
|
168
|
+
}
|
|
169
|
+
return output;
|
|
170
|
+
}
|
|
171
|
+
export async function launchTui(argv=[]){
|
|
172
|
+
if(!process.stdin.isTTY||!process.stdout.isTTY)throw new Error('Requires an interactive terminal (TTY). Run claude-usage tui in a terminal.');
|
|
173
|
+
const i=argv.indexOf('--root');const roots=i>=0&&argv[i+1]?[path.resolve(argv[i+1])]:projectRoots();const ci=argv.indexOf('--cwd');const cwd=ci>=0&&argv[ci+1]?path.resolve(argv[ci+1]):process.cwd();const explorer=createExplorer(ingest(roots),{cwd});
|
|
174
|
+
readline.emitKeypressEvents(process.stdin);process.stdin.setRawMode(true);process.stdin.resume();process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
175
|
+
let previousFrame=null;
|
|
176
|
+
const draw=()=>{const frame=explorer.render();const changes=screenDiff(previousFrame,frame);if(changes)process.stdout.write(changes);previousFrame=frame;};
|
|
177
|
+
let lastDimensions='';
|
|
178
|
+
const resize=()=>{const dimensions=`${process.stdout.columns}x${process.stdout.rows}`;if(dimensions===lastDimensions)return;lastDimensions=dimensions;previousFrame=null;process.stdout.write('\x1b[H\x1b[J');draw();};
|
|
179
|
+
const close=()=>{clearInterval(liveTimer);process.stdin.off('keypress',onKey);process.stdout.off('resize',resize);process.stdin.setRawMode(false);process.stdin.pause();process.stdout.write('\x1b[?25h\x1b[?1049l');};
|
|
180
|
+
let prompting=false,checking=false;
|
|
181
|
+
// Cheap metadata scans; only reparse transcripts when their size/mtime changes.
|
|
182
|
+
let lastSignature=historySignature(roots);
|
|
183
|
+
const liveTimer=setInterval(()=>{
|
|
184
|
+
if(!explorer.liveState.enabled||prompting||checking)return;
|
|
185
|
+
checking=true;
|
|
186
|
+
try{
|
|
187
|
+
const next=historySignature(roots);
|
|
188
|
+
if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;}
|
|
189
|
+
explorer.refreshPlan();draw();
|
|
190
|
+
explorer.setLiveState({lastCheck:Date.now()});
|
|
191
|
+
}catch(e){explorer.setLiveState({error:e.message});draw();}
|
|
192
|
+
finally{checking=false;}
|
|
193
|
+
},5000);
|
|
194
|
+
liveTimer.unref();
|
|
195
|
+
async function ask(q){prompting=true;process.stdin.setRawMode(false);previousFrame=null;process.stdout.write('\x1b[?25h\x1b[H\x1b[2J'+q+'\n> ');const rl=readline.createInterface({input:process.stdin,output:process.stdout,terminal:true});return new Promise(resolve=>rl.question('',a=>{rl.close();process.stdin.resume();process.stdin.setRawMode(true);prompting=false;process.stdout.write('\x1b[?25l');resolve(a);}));}
|
|
196
|
+
async function onKey(str,key={}){
|
|
197
|
+
if(prompting)return;if((key.ctrl&&key.name==='c')||str==='q'){close();return;}
|
|
198
|
+
const len=explorer.rows().length;
|
|
199
|
+
if(key.name==='up'||str==='k')explorer.selection=Math.max(0,explorer.selection-1);
|
|
200
|
+
else if(key.name==='down'||str==='j')explorer.selection=Math.min(len-1,explorer.selection+1);
|
|
201
|
+
else if(key.name==='pageup')explorer.selection=Math.max(0,explorer.selection-10);
|
|
202
|
+
else if(key.name==='pagedown')explorer.selection=Math.min(len-1,explorer.selection+10);
|
|
203
|
+
else if(key.name==='return'){
|
|
204
|
+
const action=explorer.open();
|
|
205
|
+
if(action==='custom'){const s=(await ask('Custom inclusive dates YYYY-MM-DD:YYYY-MM-DD (Enter to cancel):')).trim();if(s){try{timeWindow(s);explorer.setPeriod(s);}catch(e){explorer.message=e.message;}}}
|
|
206
|
+
if(action==='rescan')explorer.rescan(roots);
|
|
207
|
+
if(action==='billing'||action?.startsWith('billing-required:')){
|
|
208
|
+
const n=Number(await ask('Billing renewal day of month (1–31):'));
|
|
209
|
+
const zone=(await ask('IANA timezone (e.g. Europe/Athens, Europe/Amsterdam, UTC):')).trim()||'UTC';
|
|
210
|
+
const time=(await ask('Renewal time HH:MM (24h, default 00:00):')).trim()||'00:00';
|
|
211
|
+
const match=/^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
|
|
212
|
+
let validZone=true;try{new Intl.DateTimeFormat('en-US',{timeZone:zone});}catch{validZone=false;}
|
|
213
|
+
if(Number.isInteger(n)&&n>=1&&n<=31&&match&&validZone){explorer.setBillingDay(n,zone,Number(match[1]),Number(match[2]));if(action?.startsWith('billing-required:'))explorer.setPeriod(action.split(':')[1]);}
|
|
214
|
+
else explorer.message='Invalid billing day, timezone, or time; nothing saved.';
|
|
215
|
+
}
|
|
216
|
+
if(action==='rates'){
|
|
217
|
+
const model=await ask('Exact model ID (e.g. claude-sonnet-5-5):');
|
|
218
|
+
const raw=await ask('Verified USD per 1M tokens: input,output,cache-write,cache-read (comma separated):');
|
|
219
|
+
const nums=raw.split(',').map(x=>Number(x.trim()));
|
|
220
|
+
if(model.trim()&&nums.length===4&&nums.every(n=>Number.isFinite(n)&&n>=0))explorer.setModelRate(model.trim(),nums);else explorer.message='Invalid rates; no changes saved.';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if(key.name==='backspace'||key.name==='escape')explorer.restore();
|
|
224
|
+
else if(str==='1')explorer.switchView('projects');
|
|
225
|
+
else if(str==='2')explorer.switchView('sessions');
|
|
226
|
+
else if(str==='3')explorer.switchView('models');
|
|
227
|
+
else if(str==='m'){explorer.nav('models');}
|
|
228
|
+
else if(str==='d')explorer.nav('periods');
|
|
229
|
+
else if(str==='b')explorer.nav('settings');
|
|
230
|
+
else if(str==='o')explorer.nav('sort');
|
|
231
|
+
else if(str==='s')explorer.setSort(explorer.sort);
|
|
232
|
+
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
|
+
else if(str==='/')explorer.setFilter(await ask('Filter table (empty clears):'));
|
|
234
|
+
else if(str==='r'){explorer.rescan(roots);lastSignature=historySignature(roots);}
|
|
235
|
+
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.');}
|
|
237
|
+
draw();
|
|
238
|
+
}
|
|
239
|
+
process.stdin.on('keypress',onKey);process.stdout.on('resize',resize);resize();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Scan only metadata on a 5-second interval. The ingestion cache handles unchanged JSONL.
|
|
243
|
+
export function historySignature(roots){
|
|
244
|
+
const entries=[];
|
|
245
|
+
for(const root of roots){
|
|
246
|
+
const pending=[root];
|
|
247
|
+
while(pending.length){
|
|
248
|
+
const dir=pending.pop();let children;
|
|
249
|
+
try{children=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
|
|
250
|
+
for(const child of children){
|
|
251
|
+
const file=path.join(dir,child.name);
|
|
252
|
+
if(child.isDirectory())pending.push(file);
|
|
253
|
+
else if(child.isFile()&&child.name.endsWith('.jsonl')){
|
|
254
|
+
try{const stat=fs.statSync(file);entries.push(`${file}:${stat.size}:${stat.mtimeMs}`);}catch{}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
entries.sort();return entries.join('\n');
|
|
260
|
+
}
|