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 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
+ [![npm version](https://img.shields.io/npm/v/howmuchleft)](https://www.npmjs.com/package/howmuchleft)
4
+ [![npm downloads](https://img.shields.io/npm/dm/howmuchleft)](https://www.npmjs.com/package/howmuchleft)
5
+ [![license](https://img.shields.io/npm/l/howmuchleft)](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
6
+ [![node](https://img.shields.io/node/v/howmuchleft)](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,5 @@
1
+ {
2
+ "progressLength": 12,
3
+ "emptyBgDark": 236,
4
+ "emptyBgLight": 252
5
+ }
@@ -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
+ }