howmuchleft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/bin/cli.js +186 -0
- package/config.example.json +5 -0
- package/lib/statusline.js +619 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smm-h
|
|
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
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# HowMuchLeft
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/howmuchleft)
|
|
4
|
+
[](https://www.npmjs.com/package/howmuchleft)
|
|
5
|
+
[](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
|
|
8
|
+
Pixel-perfect progress bars showing how much context and usage you have left, right in your Claude Code statusline.
|
|
9
|
+
|
|
10
|
+
Three bars with sub-cell precision (using Unicode fractional block characters):
|
|
11
|
+
|
|
12
|
+
- **Context window** -- how full your conversation context is, plus subscription tier and model name
|
|
13
|
+
- **5-hour usage** -- your rolling 5-hour rate limit utilization, time until reset, git branch/changes, lines added/removed
|
|
14
|
+
- **Weekly usage** -- your rolling 7-day rate limit utilization, time until reset, current directory
|
|
15
|
+
|
|
16
|
+
Bars shift from green to yellow to orange to red as usage increases. Stale data is prefixed with `~`. Works with Pro, Max 5x, Max 20x, and Team subscriptions. API key users see an "API" label with context bar only.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g howmuchleft
|
|
22
|
+
howmuchleft --install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For multiple Claude Code subscriptions:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
howmuchleft --install ~/.claude-work
|
|
29
|
+
howmuchleft --install ~/.claude-personal
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Config file: `~/.config/howmuchleft.json`
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"progressLength": 12,
|
|
39
|
+
"emptyBgDark": 236,
|
|
40
|
+
"emptyBgLight": 252
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Field | Default | Description |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `progressLength` | `12` | Bar width in characters (3-40) |
|
|
47
|
+
| `emptyBgDark` | `236` | 256-color index for empty bar background in dark terminals |
|
|
48
|
+
| `emptyBgLight` | `252` | 256-color index for empty bar background in light terminals |
|
|
49
|
+
|
|
50
|
+
Check current config:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
howmuchleft --config
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
Claude Code invokes `howmuchleft` as a child process on each statusline render, piping a JSON object to stdin with model info, context window usage, cwd, and cost data. The script:
|
|
59
|
+
|
|
60
|
+
1. Reads the JSON from stdin
|
|
61
|
+
2. Fetches usage data from Anthropic's OAuth API (cached for 60s, with stale-data fallback)
|
|
62
|
+
3. Auto-refreshes expired OAuth tokens
|
|
63
|
+
4. Runs `git status` for branch/change info (in parallel with the API call)
|
|
64
|
+
5. Renders three lines of progress bars to stdout
|
|
65
|
+
|
|
66
|
+
## CLI
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
howmuchleft [config-dir] Run the statusline (called by Claude Code)
|
|
70
|
+
howmuchleft --install [config-dir] Add to Claude Code settings
|
|
71
|
+
howmuchleft --uninstall [config-dir] Remove from Claude Code settings
|
|
72
|
+
howmuchleft --config Show config path and current settings
|
|
73
|
+
howmuchleft --version Show version
|
|
74
|
+
howmuchleft --help Show help
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Uninstall
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
howmuchleft --uninstall
|
|
81
|
+
npm uninstall -g howmuchleft
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
- Node.js >= 18
|
|
87
|
+
- Claude Code with OAuth login (Pro/Max/Team subscription)
|
|
88
|
+
- `git` (optional, for branch/change display)
|
|
89
|
+
- `gsettings` (Linux/GNOME) or `defaults` (macOS) for dark/light mode detection
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* HowMuchLeft CLI
|
|
4
|
+
*
|
|
5
|
+
* When invoked with no flags (or just a config dir arg), runs the statusline.
|
|
6
|
+
* Flags: --install, --uninstall, --config, --help, --version
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { main, CONFIG_PATH } = require('../lib/statusline');
|
|
13
|
+
|
|
14
|
+
const VERSION = require('../package.json').version;
|
|
15
|
+
|
|
16
|
+
// --- Helpers ---
|
|
17
|
+
|
|
18
|
+
function resolveClaudeDir(args) {
|
|
19
|
+
// Find the first arg that isn't a flag (the Claude config dir)
|
|
20
|
+
const dir = args.find(a => !a.startsWith('-'));
|
|
21
|
+
if (dir) {
|
|
22
|
+
if (dir.startsWith('~')) return path.join(os.homedir(), dir.slice(1));
|
|
23
|
+
return path.resolve(dir);
|
|
24
|
+
}
|
|
25
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getStatuslineCommand(claudeDir) {
|
|
29
|
+
return `howmuchleft ${claudeDir.replace(os.homedir(), '~')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSettingsJson(claudeDir) {
|
|
33
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeSettingsJson(claudeDir, settings) {
|
|
42
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
43
|
+
// Ensure directory exists
|
|
44
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Commands ---
|
|
49
|
+
|
|
50
|
+
function showHelp() {
|
|
51
|
+
console.log(`howmuchleft v${VERSION}
|
|
52
|
+
Pixel-perfect progress bars for your Claude Code statusline.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
howmuchleft [claude-config-dir] Run the statusline (called by Claude Code)
|
|
56
|
+
howmuchleft --install [config-dir] Add howmuchleft to your Claude Code settings
|
|
57
|
+
howmuchleft --uninstall [config-dir] Remove howmuchleft from your Claude Code settings
|
|
58
|
+
howmuchleft --config Show config file path and current settings
|
|
59
|
+
howmuchleft --version Show version
|
|
60
|
+
|
|
61
|
+
Config file: ${CONFIG_PATH}
|
|
62
|
+
{
|
|
63
|
+
"progressLength": 12, Bar width in characters (3-40, default 12)
|
|
64
|
+
"emptyBgDark": 236, 256-color index for empty bar in dark mode
|
|
65
|
+
"emptyBgLight": 252 256-color index for empty bar in light mode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
howmuchleft --install
|
|
70
|
+
howmuchleft --install ~/.claude-work`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function showVersion() {
|
|
74
|
+
console.log(VERSION);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function install(args) {
|
|
78
|
+
const claudeDir = resolveClaudeDir(args.filter(a => a !== '--install'));
|
|
79
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
80
|
+
const settings = readSettingsJson(claudeDir);
|
|
81
|
+
const command = getStatuslineCommand(claudeDir);
|
|
82
|
+
|
|
83
|
+
if (settings.statusLine) {
|
|
84
|
+
console.log(`Current statusLine in ${settingsPath}:`);
|
|
85
|
+
console.log(` ${JSON.stringify(settings.statusLine)}`);
|
|
86
|
+
console.log();
|
|
87
|
+
|
|
88
|
+
// Check if it's already howmuchleft
|
|
89
|
+
if (settings.statusLine.command && settings.statusLine.command.includes('howmuchleft')) {
|
|
90
|
+
console.log('howmuchleft is already installed. To update the command:');
|
|
91
|
+
console.log(' howmuchleft --uninstall && howmuchleft --install');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('A statusLine is already configured. Overwrite? (y/N)');
|
|
96
|
+
process.stdin.setEncoding('utf8');
|
|
97
|
+
process.stdin.once('data', (answer) => {
|
|
98
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
99
|
+
console.log('Aborted.');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
doInstall(claudeDir, settings, command, settingsPath);
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
doInstall(claudeDir, settings, command, settingsPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function doInstall(claudeDir, settings, command, settingsPath) {
|
|
111
|
+
settings.statusLine = {
|
|
112
|
+
type: 'command',
|
|
113
|
+
command,
|
|
114
|
+
padding: 0,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
writeSettingsJson(claudeDir, settings);
|
|
118
|
+
console.log(`Installed. Added to ${settingsPath}:`);
|
|
119
|
+
console.log(` ${JSON.stringify(settings.statusLine, null, 2).replace(/\n/g, '\n ')}`);
|
|
120
|
+
console.log();
|
|
121
|
+
console.log('Restart Claude Code to see the statusline.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function uninstall(args) {
|
|
125
|
+
const claudeDir = resolveClaudeDir(args.filter(a => a !== '--uninstall'));
|
|
126
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
127
|
+
const settings = readSettingsJson(claudeDir);
|
|
128
|
+
|
|
129
|
+
if (!settings.statusLine) {
|
|
130
|
+
console.log(`No statusLine configured in ${settingsPath}.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (settings.statusLine.command && !settings.statusLine.command.includes('howmuchleft')) {
|
|
135
|
+
console.log(`statusLine in ${settingsPath} is not howmuchleft:`);
|
|
136
|
+
console.log(` ${JSON.stringify(settings.statusLine)}`);
|
|
137
|
+
console.log('Not removing. Edit settings.json manually to remove.');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
delete settings.statusLine;
|
|
142
|
+
writeSettingsJson(claudeDir, settings);
|
|
143
|
+
console.log(`Removed statusLine from ${settingsPath}.`);
|
|
144
|
+
console.log('Restart Claude Code to apply.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function showConfig() {
|
|
148
|
+
console.log(`Config file: ${CONFIG_PATH}`);
|
|
149
|
+
console.log();
|
|
150
|
+
try {
|
|
151
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
152
|
+
console.log('Current settings:');
|
|
153
|
+
console.log(` progressLength: ${config.progressLength ?? '(default: 12)'}`);
|
|
154
|
+
console.log(` emptyBgDark: ${config.emptyBgDark ?? '(default: 236)'}`);
|
|
155
|
+
console.log(` emptyBgLight: ${config.emptyBgLight ?? '(default: 252)'}`);
|
|
156
|
+
} catch {
|
|
157
|
+
console.log('No config file found. Using defaults:');
|
|
158
|
+
console.log(' progressLength: 12');
|
|
159
|
+
console.log(' emptyBgDark: 236');
|
|
160
|
+
console.log(' emptyBgLight: 252');
|
|
161
|
+
console.log();
|
|
162
|
+
console.log(`Create one with: cp ${path.resolve(__dirname, '..', 'config.example.json')} ${CONFIG_PATH}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Main ---
|
|
167
|
+
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
|
|
170
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
171
|
+
showHelp();
|
|
172
|
+
} else if (args.includes('--version') || args.includes('-v')) {
|
|
173
|
+
showVersion();
|
|
174
|
+
} else if (args.includes('--install')) {
|
|
175
|
+
install(args);
|
|
176
|
+
} else if (args.includes('--uninstall')) {
|
|
177
|
+
uninstall(args);
|
|
178
|
+
} else if (args.includes('--config')) {
|
|
179
|
+
showConfig();
|
|
180
|
+
} else {
|
|
181
|
+
// Default: run the statusline
|
|
182
|
+
main().catch(err => {
|
|
183
|
+
console.error('Statusline error:', err.message);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HowMuchLeft - Claude Code Statusline
|
|
3
|
+
*
|
|
4
|
+
* Displays a 3-line statusline with pixel-perfect progress bars:
|
|
5
|
+
* 1. Context usage bar, subscription tier, model name
|
|
6
|
+
* 2. 5-hour usage bar + time to reset, git branch/changes, lines added/removed
|
|
7
|
+
* 3. Weekly usage bar + time to reset, current directory
|
|
8
|
+
*
|
|
9
|
+
* Claude Code spawns this as a child process, piping JSON to stdin with
|
|
10
|
+
* model info, context window usage, cwd, and cost data.
|
|
11
|
+
*
|
|
12
|
+
* Fetches real usage data from Anthropic's OAuth API (with caching),
|
|
13
|
+
* and auto-refreshes expired OAuth tokens.
|
|
14
|
+
* API key users (no OAuth) see "API" label with no usage bars.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const https = require('https');
|
|
20
|
+
const { execFile, execFileSync } = require('child_process');
|
|
21
|
+
const { promisify } = require('util');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
// -- OS dark/light mode detection --
|
|
27
|
+
// Cached per-process (theme won't change mid-render).
|
|
28
|
+
let _isDark = null;
|
|
29
|
+
function isDarkMode() {
|
|
30
|
+
if (_isDark !== null) return _isDark;
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform === 'darwin') {
|
|
33
|
+
// Returns "Dark" in dark mode, throws in light mode (key doesn't exist)
|
|
34
|
+
execFileSync('defaults', ['read', '-g', 'AppleInterfaceStyle'], { timeout: 1000 });
|
|
35
|
+
_isDark = true;
|
|
36
|
+
} else {
|
|
37
|
+
const scheme = execFileSync('gsettings', [
|
|
38
|
+
'get', 'org.gnome.desktop.interface', 'color-scheme',
|
|
39
|
+
], { encoding: 'utf8', timeout: 1000 }).trim();
|
|
40
|
+
_isDark = scheme.includes('prefer-dark');
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// macOS light mode throws (key absent), Linux failure defaults to dark
|
|
44
|
+
_isDark = process.platform === 'darwin' ? false : true;
|
|
45
|
+
}
|
|
46
|
+
return _isDark;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// -- ANSI color codes --
|
|
50
|
+
const colors = {
|
|
51
|
+
reset: '\x1b[0m',
|
|
52
|
+
bold: '\x1b[1m',
|
|
53
|
+
dim: '\x1b[2m',
|
|
54
|
+
green: '\x1b[32m',
|
|
55
|
+
yellow: '\x1b[33m',
|
|
56
|
+
orange: '\x1b[38;5;208m',
|
|
57
|
+
red: '\x1b[31m',
|
|
58
|
+
cyan: '\x1b[36m',
|
|
59
|
+
magenta: '\x1b[35m',
|
|
60
|
+
white: '\x1b[37m',
|
|
61
|
+
gray: '\x1b[90m',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// -- Subscription tier display names --
|
|
65
|
+
const TIER_NAMES = {
|
|
66
|
+
'default_claude_pro': 'Pro',
|
|
67
|
+
'default_claude_max_5x': 'Max 5x',
|
|
68
|
+
'default_claude_max_20x': 'Max 20x',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// -- Progress bar: left fractional block characters for sub-cell precision --
|
|
72
|
+
const FRACTIONAL_CHARS = ['\u258F','\u258E','\u258D','\u258C','\u258B','\u258A','\u2589'];
|
|
73
|
+
|
|
74
|
+
// Config is loaded from ~/.config/howmuchleft.json (XDG-style, install-method-agnostic).
|
|
75
|
+
// Cached per-process to avoid redundant fs reads (called once per progressBar, 3x per render).
|
|
76
|
+
const CONFIG_PATH = path.join(os.homedir(), '.config', 'howmuchleft.json');
|
|
77
|
+
|
|
78
|
+
let _barConfig = null;
|
|
79
|
+
function getBarConfig() {
|
|
80
|
+
if (_barConfig) return _barConfig;
|
|
81
|
+
try {
|
|
82
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
83
|
+
const w = Number(config.progressLength);
|
|
84
|
+
const dark = isDarkMode();
|
|
85
|
+
const rawBg = Number(dark ? config.emptyBgDark : config.emptyBgLight);
|
|
86
|
+
_barConfig = {
|
|
87
|
+
width: (Number.isInteger(w) && w >= 3 && w <= 40) ? w : 12,
|
|
88
|
+
emptyBg: (Number.isInteger(rawBg) && rawBg >= 0 && rawBg <= 255) ? rawBg : (dark ? 236 : 252),
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
_barConfig = { width: 12, emptyBg: isDarkMode() ? 236 : 252 };
|
|
92
|
+
}
|
|
93
|
+
return _barConfig;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
97
|
+
|
|
98
|
+
// Cache TTL: 60s for success, 5min for failures (avoids hammering a dead API)
|
|
99
|
+
const CACHE_TTL_MS = 60_000;
|
|
100
|
+
const ERROR_CACHE_TTL_MS = 300_000;
|
|
101
|
+
|
|
102
|
+
function resolvePath(p) {
|
|
103
|
+
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
|
|
104
|
+
return path.resolve(p);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getClaudeDir() {
|
|
108
|
+
const arg = process.argv[2];
|
|
109
|
+
if (!arg || arg.startsWith('-')) return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
110
|
+
return resolvePath(arg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readCredentialsFile(claudeDir) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Determine auth type and subscription display name.
|
|
123
|
+
* OAuth users: "Pro", "Max 5x", "Max 20x", "Team Pro", etc.
|
|
124
|
+
* API key users: "API" (no usage API available).
|
|
125
|
+
*/
|
|
126
|
+
function getAuthInfo(oauth) {
|
|
127
|
+
if (!oauth.accessToken) {
|
|
128
|
+
return { isOAuth: false, subscriptionName: 'API' };
|
|
129
|
+
}
|
|
130
|
+
const baseName = TIER_NAMES[oauth.rateLimitTier] || 'Pro';
|
|
131
|
+
const subscriptionName = (oauth.subscriptionType === 'team' && !baseName.startsWith('Team'))
|
|
132
|
+
? `Team ${baseName}`
|
|
133
|
+
: baseName;
|
|
134
|
+
return { isOAuth: true, subscriptionName };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Atomic file write using tmpfile + rename(2).
|
|
139
|
+
* rename(2) is atomic on POSIX for same-filesystem operations,
|
|
140
|
+
* so readers always see either the complete old or complete new file.
|
|
141
|
+
*/
|
|
142
|
+
function writeFileAtomic(filePath, data) {
|
|
143
|
+
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
144
|
+
try {
|
|
145
|
+
fs.writeFileSync(tmpPath, data);
|
|
146
|
+
fs.renameSync(tmpPath, filePath);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Refresh an expired OAuth token.
|
|
155
|
+
*
|
|
156
|
+
* Validates response before writing back:
|
|
157
|
+
* - Non-2xx = failure
|
|
158
|
+
* - expires_in validated to prevent NaN poisoning (NaN -> JSON null -> perpetual refresh loop)
|
|
159
|
+
* - Atomic write to prevent corruption
|
|
160
|
+
*
|
|
161
|
+
* Known limitation: two Claude sessions refreshing simultaneously can race.
|
|
162
|
+
* OAuth refresh tokens are single-use, so the loser's token is invalidated.
|
|
163
|
+
*/
|
|
164
|
+
function refreshToken(claudeDir, credFile, oauth) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
if (!oauth.refreshToken) { resolve(null); return; }
|
|
167
|
+
|
|
168
|
+
const postData = `grant_type=refresh_token&refresh_token=${encodeURIComponent(oauth.refreshToken)}&client_id=${OAUTH_CLIENT_ID}`;
|
|
169
|
+
const req = https.request({
|
|
170
|
+
hostname: 'console.anthropic.com',
|
|
171
|
+
path: '/v1/oauth/token',
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: {
|
|
174
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
175
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
176
|
+
},
|
|
177
|
+
}, (res) => {
|
|
178
|
+
let data = '';
|
|
179
|
+
res.on('data', chunk => data += chunk);
|
|
180
|
+
res.on('end', () => {
|
|
181
|
+
try {
|
|
182
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
183
|
+
resolve(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const tokens = JSON.parse(data);
|
|
187
|
+
if (!tokens.access_token || !tokens.refresh_token) { resolve(null); return; }
|
|
188
|
+
|
|
189
|
+
// Validate expires_in: NaN would propagate as JSON null, causing perpetual refresh
|
|
190
|
+
const rawTTL = Number(tokens.expires_in);
|
|
191
|
+
const ttl = (isFinite(rawTTL) && rawTTL > 0)
|
|
192
|
+
? Math.min(rawTTL, 86400) // cap at 24h
|
|
193
|
+
: 28800; // default 8h if invalid
|
|
194
|
+
|
|
195
|
+
credFile.claudeAiOauth.accessToken = tokens.access_token;
|
|
196
|
+
credFile.claudeAiOauth.refreshToken = tokens.refresh_token;
|
|
197
|
+
credFile.claudeAiOauth.expiresAt = Date.now() + (ttl * 1000);
|
|
198
|
+
|
|
199
|
+
const credPath = path.join(claudeDir, '.credentials.json');
|
|
200
|
+
writeFileAtomic(credPath, JSON.stringify(credFile, null, 2));
|
|
201
|
+
resolve(tokens.access_token);
|
|
202
|
+
} catch { resolve(null); }
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
req.on('error', () => resolve(null));
|
|
206
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
|
207
|
+
req.write(postData);
|
|
208
|
+
req.end();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Get a valid access token, refreshing if expired (with 60s buffer)
|
|
213
|
+
async function getValidToken(claudeDir) {
|
|
214
|
+
const credFile = readCredentialsFile(claudeDir);
|
|
215
|
+
if (!credFile) return null;
|
|
216
|
+
const oauth = credFile.claudeAiOauth || {};
|
|
217
|
+
if (!oauth.accessToken) return null;
|
|
218
|
+
|
|
219
|
+
const expiresAt = oauth.expiresAt || 0;
|
|
220
|
+
if (Date.now() < expiresAt - 60_000) return oauth.accessToken;
|
|
221
|
+
return await refreshToken(claudeDir, credFile, oauth);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fetch usage data from Anthropic's OAuth usage API.
|
|
226
|
+
* - 2xx: parse and return
|
|
227
|
+
* - 401/403: return { error: 'auth' } (permanent, cache and stop retrying)
|
|
228
|
+
* - 429/5xx: return null (transient, retry next cycle)
|
|
229
|
+
*/
|
|
230
|
+
function fetchUsageFromAPI(accessToken) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
if (!accessToken) { resolve(null); return; }
|
|
233
|
+
|
|
234
|
+
const req = https.request({
|
|
235
|
+
hostname: 'api.anthropic.com',
|
|
236
|
+
path: '/api/oauth/usage',
|
|
237
|
+
method: 'GET',
|
|
238
|
+
headers: {
|
|
239
|
+
'Accept': 'application/json',
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
'User-Agent': 'howmuchleft',
|
|
242
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
243
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
244
|
+
},
|
|
245
|
+
}, (res) => {
|
|
246
|
+
let data = '';
|
|
247
|
+
res.on('data', chunk => data += chunk);
|
|
248
|
+
res.on('end', () => {
|
|
249
|
+
try {
|
|
250
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
251
|
+
resolve({ error: 'auth' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
255
|
+
resolve(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const parsed = JSON.parse(data);
|
|
259
|
+
if (parsed.type === 'error') {
|
|
260
|
+
resolve(null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
resolve(parsed);
|
|
264
|
+
} catch { resolve(null); }
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
req.on('error', () => resolve(null));
|
|
268
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(null); });
|
|
269
|
+
req.end();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get usage data with caching and stale-data fallback.
|
|
275
|
+
*
|
|
276
|
+
* Cache uses absolute timestamps (resetAt) to avoid drift.
|
|
277
|
+
* When a quota reset time has passed, forces refresh regardless of TTL.
|
|
278
|
+
* On API failure, falls back to last-known-good data (marked stale).
|
|
279
|
+
*/
|
|
280
|
+
async function getUsageData(claudeDir, forceRefresh = false) {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const cacheFile = path.join(claudeDir, '.statusline-cache.json');
|
|
283
|
+
|
|
284
|
+
let cache = null;
|
|
285
|
+
try {
|
|
286
|
+
if (fs.existsSync(cacheFile)) {
|
|
287
|
+
cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
288
|
+
}
|
|
289
|
+
} catch {}
|
|
290
|
+
|
|
291
|
+
if (!forceRefresh && cache && cache.timestamp) {
|
|
292
|
+
const age = now - cache.timestamp;
|
|
293
|
+
const ttl = cache.status === 'error' ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
|
|
294
|
+
|
|
295
|
+
// Force refresh if a quota reset has passed (cached percent is definitely wrong)
|
|
296
|
+
const fiveHourResetPassed = cache.fiveHour?.resetAt != null && now >= cache.fiveHour.resetAt;
|
|
297
|
+
const weeklyResetPassed = cache.weekly?.resetAt != null && now >= cache.weekly.resetAt;
|
|
298
|
+
const resetPassed = fiveHourResetPassed || weeklyResetPassed;
|
|
299
|
+
|
|
300
|
+
if (age < ttl && !resetPassed) {
|
|
301
|
+
return {
|
|
302
|
+
stale: false,
|
|
303
|
+
fiveHour: {
|
|
304
|
+
percent: cache.fiveHour?.percent ?? null,
|
|
305
|
+
resetIn: cache.fiveHour?.resetAt != null ? Math.max(0, cache.fiveHour.resetAt - now) : null,
|
|
306
|
+
},
|
|
307
|
+
weekly: {
|
|
308
|
+
percent: cache.weekly?.percent ?? null,
|
|
309
|
+
resetIn: cache.weekly?.resetAt != null ? Math.max(0, cache.weekly.resetAt - now) : null,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const accessToken = await getValidToken(claudeDir);
|
|
316
|
+
const apiData = await fetchUsageFromAPI(accessToken);
|
|
317
|
+
|
|
318
|
+
// Auth error: cache failure for ERROR_CACHE_TTL_MS, fall back to last-known-good
|
|
319
|
+
if (apiData && apiData.error === 'auth') {
|
|
320
|
+
try {
|
|
321
|
+
writeFileAtomic(cacheFile, JSON.stringify({
|
|
322
|
+
timestamp: now,
|
|
323
|
+
status: 'error',
|
|
324
|
+
fiveHour: cache?.fiveHour || { percent: null, resetAt: null },
|
|
325
|
+
weekly: cache?.weekly || { percent: null, resetAt: null },
|
|
326
|
+
}));
|
|
327
|
+
} catch {}
|
|
328
|
+
|
|
329
|
+
if (cache?.fiveHour?.percent != null || cache?.weekly?.percent != null) {
|
|
330
|
+
return {
|
|
331
|
+
stale: true,
|
|
332
|
+
fiveHour: {
|
|
333
|
+
percent: cache.fiveHour?.percent ?? null,
|
|
334
|
+
resetIn: cache.fiveHour?.resetAt != null ? Math.max(0, cache.fiveHour.resetAt - now) : null,
|
|
335
|
+
},
|
|
336
|
+
weekly: {
|
|
337
|
+
percent: cache.weekly?.percent ?? null,
|
|
338
|
+
resetIn: cache.weekly?.resetAt != null ? Math.max(0, cache.weekly.resetAt - now) : null,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { stale: false, fiveHour: { percent: null, resetIn: null }, weekly: { percent: null, resetIn: null } };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Transient failure: cache and fall back to stale data
|
|
346
|
+
if (!apiData) {
|
|
347
|
+
try {
|
|
348
|
+
writeFileAtomic(cacheFile, JSON.stringify({
|
|
349
|
+
timestamp: now,
|
|
350
|
+
status: 'error',
|
|
351
|
+
fiveHour: cache?.fiveHour || { percent: null, resetAt: null },
|
|
352
|
+
weekly: cache?.weekly || { percent: null, resetAt: null },
|
|
353
|
+
}));
|
|
354
|
+
} catch {}
|
|
355
|
+
|
|
356
|
+
if (cache?.fiveHour?.percent != null || cache?.weekly?.percent != null) {
|
|
357
|
+
return {
|
|
358
|
+
stale: true,
|
|
359
|
+
fiveHour: {
|
|
360
|
+
percent: cache.fiveHour?.percent ?? null,
|
|
361
|
+
resetIn: cache.fiveHour?.resetAt != null ? Math.max(0, cache.fiveHour.resetAt - now) : null,
|
|
362
|
+
},
|
|
363
|
+
weekly: {
|
|
364
|
+
percent: cache.weekly?.percent ?? null,
|
|
365
|
+
resetIn: cache.weekly?.resetAt != null ? Math.max(0, cache.weekly.resetAt - now) : null,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return { stale: false, fiveHour: { percent: null, resetIn: null }, weekly: { percent: null, resetIn: null } };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Success: parse and cache with absolute timestamps
|
|
373
|
+
let usage = {
|
|
374
|
+
stale: false,
|
|
375
|
+
fiveHour: { percent: null, resetIn: null },
|
|
376
|
+
weekly: { percent: null, resetIn: null },
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
let cacheEntry = {
|
|
380
|
+
timestamp: now,
|
|
381
|
+
status: 'ok',
|
|
382
|
+
fiveHour: { percent: null, resetAt: null },
|
|
383
|
+
weekly: { percent: null, resetAt: null },
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (apiData.five_hour) {
|
|
387
|
+
usage.fiveHour.percent = apiData.five_hour.utilization ?? 0;
|
|
388
|
+
cacheEntry.fiveHour.percent = usage.fiveHour.percent;
|
|
389
|
+
if (apiData.five_hour.resets_at) {
|
|
390
|
+
const resetAt = new Date(apiData.five_hour.resets_at).getTime();
|
|
391
|
+
cacheEntry.fiveHour.resetAt = resetAt;
|
|
392
|
+
usage.fiveHour.resetIn = Math.max(0, resetAt - now);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (apiData.seven_day) {
|
|
397
|
+
usage.weekly.percent = apiData.seven_day.utilization ?? 0;
|
|
398
|
+
cacheEntry.weekly.percent = usage.weekly.percent;
|
|
399
|
+
if (apiData.seven_day.resets_at) {
|
|
400
|
+
const resetAt = new Date(apiData.seven_day.resets_at).getTime();
|
|
401
|
+
cacheEntry.weekly.resetAt = resetAt;
|
|
402
|
+
usage.weekly.resetIn = Math.max(0, resetAt - now);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (usage.fiveHour.percent != null || usage.weekly.percent != null) {
|
|
407
|
+
try {
|
|
408
|
+
writeFileAtomic(cacheFile, JSON.stringify(cacheEntry));
|
|
409
|
+
} catch {}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return usage;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Read JSON from stdin (piped by Claude Code).
|
|
417
|
+
* No timeout needed -- Claude Code kills hung scripts when a new render triggers.
|
|
418
|
+
*/
|
|
419
|
+
async function readStdin() {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
let data = '';
|
|
422
|
+
process.stdin.setEncoding('utf8');
|
|
423
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
424
|
+
process.stdin.on('end', () => {
|
|
425
|
+
try { resolve(JSON.parse(data)); }
|
|
426
|
+
catch { resolve({}); }
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Color buckets: green -> yellow -> orange -> red
|
|
432
|
+
function getProgressColor(percent) {
|
|
433
|
+
if (percent < 40) return colors.green;
|
|
434
|
+
if (percent < 60) return colors.yellow;
|
|
435
|
+
if (percent < 80) return colors.orange;
|
|
436
|
+
return colors.red;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Background color variant for filled bar cells
|
|
440
|
+
function getProgressBgColor(percent) {
|
|
441
|
+
if (percent < 40) return '\x1b[42m';
|
|
442
|
+
if (percent < 60) return '\x1b[43m';
|
|
443
|
+
if (percent < 80) return '\x1b[48;5;208m';
|
|
444
|
+
return '\x1b[41m';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Render an hblock progress bar with sub-cell precision.
|
|
449
|
+
* Filled cells: bg-colored spaces. Fractional cell: fg-colored left block char.
|
|
450
|
+
* Empty cells: configurable gray background.
|
|
451
|
+
*/
|
|
452
|
+
function progressBar(percent, width) {
|
|
453
|
+
const config = getBarConfig();
|
|
454
|
+
if (width == null) width = config.width;
|
|
455
|
+
const emptyBg = `\x1b[48;5;${config.emptyBg}m`;
|
|
456
|
+
|
|
457
|
+
if (percent === null || percent === undefined) {
|
|
458
|
+
return `${emptyBg}${' '.repeat(width)}${colors.reset}`;
|
|
459
|
+
}
|
|
460
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
461
|
+
const color = getProgressColor(clamped);
|
|
462
|
+
const bgColor = getProgressBgColor(clamped);
|
|
463
|
+
const fillFrac = clamped / 100;
|
|
464
|
+
let out = '';
|
|
465
|
+
for (let i = 0; i < width; i++) {
|
|
466
|
+
const cellStart = i / width;
|
|
467
|
+
const cellEnd = (i + 1) / width;
|
|
468
|
+
if (cellEnd <= fillFrac) {
|
|
469
|
+
out += bgColor + ' ';
|
|
470
|
+
} else if (cellStart >= fillFrac) {
|
|
471
|
+
out += emptyBg + ' ';
|
|
472
|
+
} else {
|
|
473
|
+
const cellFill = (fillFrac - cellStart) / (1 / width);
|
|
474
|
+
const idx = Math.max(0, Math.min(FRACTIONAL_CHARS.length - 1, Math.floor(cellFill * FRACTIONAL_CHARS.length)));
|
|
475
|
+
out += emptyBg + color + FRACTIONAL_CHARS[idx];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return out + colors.reset;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Format a usage percentage.
|
|
483
|
+
* stale=true: prefix with ~ (approximate). null: show "?%" (no data).
|
|
484
|
+
*/
|
|
485
|
+
function formatPercent(percent, stale = false) {
|
|
486
|
+
if (percent === null || percent === undefined) {
|
|
487
|
+
return `${colors.gray}?%${colors.reset}`;
|
|
488
|
+
}
|
|
489
|
+
if (stale) {
|
|
490
|
+
return `${colors.dim}~${Math.round(percent)}%${colors.reset}`;
|
|
491
|
+
}
|
|
492
|
+
return `${colors.cyan}${Math.round(percent)}%${colors.reset}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function formatTimeRemaining(ms) {
|
|
496
|
+
if (ms === null || ms === undefined) return '?';
|
|
497
|
+
if (ms <= 0) return 'now';
|
|
498
|
+
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
499
|
+
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
500
|
+
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
|
501
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
502
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
503
|
+
return `${minutes}m`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get git branch and change count with a single async call.
|
|
508
|
+
* Uses --porcelain=v2 --branch -uno --no-renames --no-optional-locks
|
|
509
|
+
* for machine-readable output without blocking concurrent git ops.
|
|
510
|
+
*/
|
|
511
|
+
async function getGitInfo(cwd) {
|
|
512
|
+
try {
|
|
513
|
+
const { stdout } = await execFileAsync('git', [
|
|
514
|
+
'--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-uno', '--no-renames',
|
|
515
|
+
], { cwd, timeout: 3000 });
|
|
516
|
+
|
|
517
|
+
let branch = null;
|
|
518
|
+
let changes = 0;
|
|
519
|
+
|
|
520
|
+
for (const line of stdout.split('\n')) {
|
|
521
|
+
if (line.startsWith('# branch.head ')) {
|
|
522
|
+
branch = line.slice('# branch.head '.length);
|
|
523
|
+
if (branch === '(detached)') branch = 'detached';
|
|
524
|
+
} else if (/^[12u] /.test(line)) {
|
|
525
|
+
changes++;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { branch: branch || 'detached', changes, hasGit: true };
|
|
530
|
+
} catch {
|
|
531
|
+
return { branch: null, changes: 0, hasGit: false };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function shortenPath(p, maxLen = 25) {
|
|
536
|
+
if (!p || p.length <= maxLen) return p || '~';
|
|
537
|
+
const home = os.homedir();
|
|
538
|
+
if (p.startsWith(home)) p = '~' + p.slice(home.length);
|
|
539
|
+
if (p.length <= maxLen) return p;
|
|
540
|
+
const parts = p.split('/');
|
|
541
|
+
if (parts.length <= 2) return '...' + p.slice(-maxLen + 3);
|
|
542
|
+
return parts[0] + '/.../' + parts.slice(-2).join('/');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function main() {
|
|
546
|
+
const claudeDir = getClaudeDir();
|
|
547
|
+
const credFile = readCredentialsFile(claudeDir);
|
|
548
|
+
const oauth = credFile?.claudeAiOauth || {};
|
|
549
|
+
const { isOAuth, subscriptionName } = getAuthInfo(oauth);
|
|
550
|
+
const stdinData = await readStdin();
|
|
551
|
+
|
|
552
|
+
const model = stdinData.model?.display_name || stdinData.model?.id || '?';
|
|
553
|
+
const contextWindow = stdinData.context_window || {};
|
|
554
|
+
const contextUsedPercent = contextWindow.used_percentage || 0;
|
|
555
|
+
const cwd = stdinData.cwd || stdinData.workspace?.current_dir || process.cwd();
|
|
556
|
+
const cost = stdinData.cost || {};
|
|
557
|
+
|
|
558
|
+
// Fetch usage and git info in parallel (independent operations)
|
|
559
|
+
const isSessionStart = contextUsedPercent === 0;
|
|
560
|
+
const [usage, gitInfo] = await Promise.all([
|
|
561
|
+
isOAuth
|
|
562
|
+
? getUsageData(claudeDir, isSessionStart)
|
|
563
|
+
: { stale: false, fiveHour: { percent: null, resetIn: null }, weekly: { percent: null, resetIn: null } },
|
|
564
|
+
getGitInfo(cwd),
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
const lines = [];
|
|
568
|
+
|
|
569
|
+
// Line 1: context bar, subscription tier, model
|
|
570
|
+
const contextBar = progressBar(contextUsedPercent);
|
|
571
|
+
lines.push(
|
|
572
|
+
`${contextBar} ${colors.cyan}${Math.round(contextUsedPercent)}%${colors.reset} ` +
|
|
573
|
+
`${colors.magenta}${subscriptionName}${colors.reset} ` +
|
|
574
|
+
`${colors.white}${model}${colors.reset}`
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Line 2: 5hr usage bar + reset time, git branch/changes, lines added/removed
|
|
578
|
+
const fiveHourBar = progressBar(usage.fiveHour.percent);
|
|
579
|
+
const fiveHourPercent = formatPercent(usage.fiveHour.percent, usage.stale);
|
|
580
|
+
const fiveHourReset = formatTimeRemaining(usage.fiveHour.resetIn);
|
|
581
|
+
const gitStr = gitInfo.hasGit
|
|
582
|
+
? `${colors.cyan}${gitInfo.branch}${colors.reset}` +
|
|
583
|
+
(gitInfo.changes > 0 ? ` ${colors.yellow}+${gitInfo.changes}${colors.reset}` : '')
|
|
584
|
+
: `${colors.gray}no .git${colors.reset}`;
|
|
585
|
+
const added = cost.total_lines_added;
|
|
586
|
+
const removed = cost.total_lines_removed;
|
|
587
|
+
const linesStr = (added || removed)
|
|
588
|
+
? `${colors.green}+${added ?? 0}${colors.reset}/${colors.red}-${removed ?? 0}${colors.reset}`
|
|
589
|
+
: '';
|
|
590
|
+
lines.push(
|
|
591
|
+
`${fiveHourBar} ${fiveHourPercent} ` +
|
|
592
|
+
`${colors.dim}${fiveHourReset}${colors.reset} ` +
|
|
593
|
+
`${gitStr}` +
|
|
594
|
+
(linesStr ? ` ${linesStr}` : '')
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// Line 3: weekly usage bar + reset time, current directory
|
|
598
|
+
const weeklyBar = progressBar(usage.weekly.percent);
|
|
599
|
+
const weeklyPercent = formatPercent(usage.weekly.percent, usage.stale);
|
|
600
|
+
const weeklyReset = formatTimeRemaining(usage.weekly.resetIn);
|
|
601
|
+
const shortCwd = shortenPath(cwd, 25);
|
|
602
|
+
lines.push(
|
|
603
|
+
`${weeklyBar} ${weeklyPercent} ` +
|
|
604
|
+
`${colors.dim}${weeklyReset}${colors.reset} ` +
|
|
605
|
+
`${colors.white}${shortCwd}${colors.reset}`
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
console.log(lines.join('\n'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = { main, CONFIG_PATH };
|
|
612
|
+
|
|
613
|
+
// Run directly when invoked as a script (not when required by cli.js)
|
|
614
|
+
if (require.main === module) {
|
|
615
|
+
main().catch(err => {
|
|
616
|
+
console.error('Statusline error:', err.message);
|
|
617
|
+
process.exit(1);
|
|
618
|
+
});
|
|
619
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "howmuchleft",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pixel-perfect progress bars showing how much context and usage you have left, right in your Claude Code statusline",
|
|
5
|
+
"bin": {
|
|
6
|
+
"howmuchleft": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "lib/statusline.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"config.example.json"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"claude-code",
|
|
16
|
+
"statusline",
|
|
17
|
+
"progress-bar",
|
|
18
|
+
"usage",
|
|
19
|
+
"rate-limit",
|
|
20
|
+
"oauth",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "smm-h",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/smm-h/howmuchleft.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/smm-h/howmuchleft#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/smm-h/howmuchleft/issues"
|
|
32
|
+
},
|
|
33
|
+
"type": "commonjs",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|