claude-limitline 1.0.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) 2024 Tyler Gray
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,281 @@
1
+ # claude-limitline
2
+
3
+ A statusline for Claude Code showing real-time usage limits and weekly tracking.
4
+
5
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
6
+ ![Node](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)
7
+ ![TypeScript](https://img.shields.io/badge/typescript-5.3-blue.svg)
8
+
9
+ ## Features
10
+
11
+ - **5-Hour Block Limit** - Shows current usage percentage with time remaining until reset
12
+ - **7-Day Rolling Limit** - Tracks weekly usage with progress indicator
13
+ - **Real-time Tracking** - Uses Anthropic's OAuth usage API for accurate usage data
14
+ - **Progress Bar Display** - Visual progress bars for quick status checks
15
+ - **Cross-Platform** - Works on Windows, macOS, and Linux
16
+ - **Zero Runtime Dependencies** - Lightweight and fast
17
+ - **Multiple Themes** - Dark, light, nord, and gruvbox themes included
18
+
19
+ ## Prerequisites
20
+
21
+ - **Node.js** 18.0.0 or higher
22
+ - **Claude Code** CLI installed and authenticated (for OAuth token)
23
+ - **Nerd Font** (optional, for powerline symbols)
24
+
25
+ ## Installation
26
+
27
+ ### From npm (recommended)
28
+
29
+ ```bash
30
+ npm install -g claude-limitline
31
+ ```
32
+
33
+ ### From Source
34
+
35
+ ```bash
36
+ git clone https://github.com/tylergraydev/claude-limitline.git
37
+ cd claude-limitline
38
+ npm install
39
+ npm run build
40
+ npm link
41
+ ```
42
+
43
+ ### Using Docker
44
+
45
+ ```bash
46
+ # Build the image
47
+ docker build -t claude-limitline .
48
+
49
+ # Run (mount your .claude directory for OAuth token access)
50
+ docker run --rm -v ~/.claude:/root/.claude claude-limitline
51
+ ```
52
+
53
+ ## Quick Start
54
+
55
+ The easiest way to use claude-limitline is to add it directly to your Claude Code settings.
56
+
57
+ **Add to your Claude Code settings file** (`~/.claude/settings.json`):
58
+
59
+ ```json
60
+ {
61
+ "statusLine": {
62
+ "type": "command",
63
+ "command": "npx claude-limitline"
64
+ }
65
+ }
66
+ ```
67
+
68
+ That's it! The status line will now show your usage limits in Claude Code.
69
+
70
+ ### Full Settings Example
71
+
72
+ Here's a complete example with other common settings:
73
+
74
+ ```json
75
+ {
76
+ "permissions": {
77
+ "defaultMode": "default"
78
+ },
79
+ "statusLine": {
80
+ "type": "command",
81
+ "command": "npx claude-limitline"
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### Alternative: Global Install
87
+
88
+ If you prefer a global installation (slightly faster startup):
89
+
90
+ ```bash
91
+ npm install -g claude-limitline
92
+ ```
93
+
94
+ Then update your settings:
95
+
96
+ ```json
97
+ {
98
+ "statusLine": {
99
+ "type": "command",
100
+ "command": "claude-limitline"
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### Test It
106
+
107
+ Run standalone to verify it's working:
108
+
109
+ ```bash
110
+ npx claude-limitline
111
+ ```
112
+
113
+ You should see output like:
114
+ ```
115
+ ⏳ ████████░░ 45% (2h 30m left) | 📅 ██████░░░░ 62% (wk 43%)
116
+ ```
117
+
118
+ ## Configuration
119
+
120
+ Create a `.claude-limitline.json` file in your home directory (`~/.claude-limitline.json`) or current working directory:
121
+
122
+ ```json
123
+ {
124
+ "display": {
125
+ "style": "minimal",
126
+ "useNerdFonts": true
127
+ },
128
+ "block": {
129
+ "enabled": true,
130
+ "displayStyle": "bar",
131
+ "barWidth": 10,
132
+ "showTimeRemaining": true
133
+ },
134
+ "weekly": {
135
+ "enabled": true,
136
+ "displayStyle": "bar",
137
+ "barWidth": 10,
138
+ "showWeekProgress": true
139
+ },
140
+ "budget": {
141
+ "pollInterval": 15,
142
+ "warningThreshold": 80
143
+ },
144
+ "theme": "dark"
145
+ }
146
+ ```
147
+
148
+ ### Configuration Options
149
+
150
+ | Option | Description | Default |
151
+ |--------|-------------|---------|
152
+ | `display.useNerdFonts` | Use Nerd Font symbols (⏳📅) vs ASCII | `true` |
153
+ | `block.enabled` | Show 5-hour block usage | `true` |
154
+ | `block.displayStyle` | `"bar"` or `"text"` | `"bar"` |
155
+ | `block.barWidth` | Width of progress bar in characters | `10` |
156
+ | `block.showTimeRemaining` | Show time until block resets | `true` |
157
+ | `weekly.enabled` | Show 7-day rolling usage | `true` |
158
+ | `weekly.displayStyle` | `"bar"` or `"text"` | `"bar"` |
159
+ | `weekly.barWidth` | Width of progress bar in characters | `10` |
160
+ | `weekly.showWeekProgress` | Show week progress percentage | `true` |
161
+ | `budget.pollInterval` | Minutes between API calls | `15` |
162
+ | `budget.warningThreshold` | Percentage to trigger warning color | `80` |
163
+ | `theme` | Color theme name | `"dark"` |
164
+
165
+ ### Available Themes
166
+
167
+ - `dark` - Default dark theme
168
+ - `light` - Light background theme
169
+ - `nord` - Nord color palette
170
+ - `gruvbox` - Gruvbox color palette
171
+
172
+ ## How It Works
173
+
174
+ claude-limitline retrieves your Claude usage data from Anthropic's OAuth usage API. It reads your OAuth token from:
175
+
176
+ | Platform | Location |
177
+ |----------|----------|
178
+ | **Windows** | Credential Manager or `~/.claude/.credentials.json` |
179
+ | **macOS** | Keychain or `~/.claude/.credentials.json` |
180
+ | **Linux** | secret-tool (GNOME Keyring) or `~/.claude/.credentials.json` |
181
+
182
+ The usage data is cached locally to respect API rate limits. The cache duration is configurable via `budget.pollInterval` (default: 15 minutes).
183
+
184
+ ### API Response
185
+
186
+ The tool queries Anthropic's usage endpoint which returns:
187
+
188
+ - **5-hour block**: Usage percentage and reset time for the rolling 5-hour window
189
+ - **7-day rolling**: Usage percentage and reset time for the rolling 7-day window
190
+
191
+ ## Development
192
+
193
+ ### Setup
194
+
195
+ ```bash
196
+ git clone https://github.com/tylergraydev/claude-limitline.git
197
+ cd claude-limitline
198
+ npm install
199
+ ```
200
+
201
+ ### Build
202
+
203
+ ```bash
204
+ npm run build
205
+ ```
206
+
207
+ ### Development Mode (watch)
208
+
209
+ ```bash
210
+ npm run dev
211
+ ```
212
+
213
+ ### Type Checking
214
+
215
+ ```bash
216
+ npm run typecheck
217
+ ```
218
+
219
+ ### Run Locally
220
+
221
+ ```bash
222
+ node dist/index.js
223
+ ```
224
+
225
+ ## Debug Mode
226
+
227
+ Enable debug logging to troubleshoot issues:
228
+
229
+ ```bash
230
+ # Linux/macOS
231
+ CLAUDE_LIMITLINE_DEBUG=true claude-limitline
232
+
233
+ # Windows (PowerShell)
234
+ $env:CLAUDE_LIMITLINE_DEBUG="true"; claude-limitline
235
+
236
+ # Windows (CMD)
237
+ set CLAUDE_LIMITLINE_DEBUG=true && claude-limitline
238
+ ```
239
+
240
+ Debug output is written to stderr so it won't interfere with the status line output.
241
+
242
+ ## Troubleshooting
243
+
244
+ ### "No data" or empty output
245
+
246
+ 1. **Check OAuth token**: Make sure you're logged into Claude Code (`claude --login`)
247
+ 2. **Check credentials file**: Verify `~/.claude/.credentials.json` exists and contains `claudeAiOauth.accessToken`
248
+ 3. **Enable debug mode**: Run with `CLAUDE_LIMITLINE_DEBUG=true` to see detailed logs
249
+
250
+ ### Token not found
251
+
252
+ The OAuth token is stored by Claude Code when you authenticate. Try:
253
+
254
+ ```bash
255
+ # Re-authenticate with Claude Code
256
+ claude --login
257
+ ```
258
+
259
+ ### API returns errors
260
+
261
+ - Ensure your Claude subscription is active
262
+ - Check if you've exceeded API rate limits (try increasing `pollInterval`)
263
+
264
+ ## Contributing
265
+
266
+ Contributions are welcome! Please feel free to submit a Pull Request.
267
+
268
+ 1. Fork the repository
269
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
270
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
271
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
272
+ 5. Open a Pull Request
273
+
274
+ ## License
275
+
276
+ MIT License - see [LICENSE](LICENSE) for details.
277
+
278
+ ## Acknowledgments
279
+
280
+ - Inspired by [claude-powerline](https://github.com/Owloops/claude-powerline)
281
+ - Built for use with [Claude Code](https://claude.com/claude-code)
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config/loader.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import os from "os";
7
+
8
+ // src/utils/logger.ts
9
+ var DEBUG = process.env.CLAUDE_LIMITLINE_DEBUG === "true";
10
+ function debug(...args) {
11
+ if (DEBUG) {
12
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
13
+ console.error(`[${timestamp}] [DEBUG]`, ...args);
14
+ }
15
+ }
16
+
17
+ // src/config/types.ts
18
+ var DEFAULT_CONFIG = {
19
+ display: {
20
+ style: "minimal",
21
+ useNerdFonts: true
22
+ },
23
+ block: {
24
+ enabled: true,
25
+ displayStyle: "bar",
26
+ barWidth: 10,
27
+ showTimeRemaining: true
28
+ },
29
+ weekly: {
30
+ enabled: true,
31
+ displayStyle: "bar",
32
+ barWidth: 10,
33
+ showWeekProgress: true
34
+ },
35
+ budget: {
36
+ pollInterval: 15,
37
+ warningThreshold: 80
38
+ },
39
+ theme: "dark"
40
+ };
41
+
42
+ // src/config/loader.ts
43
+ function deepMerge(target, source) {
44
+ const result = { ...target };
45
+ for (const key of Object.keys(source)) {
46
+ const sourceValue = source[key];
47
+ const targetValue = target[key];
48
+ if (sourceValue !== void 0 && typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue) && typeof targetValue === "object" && targetValue !== null && !Array.isArray(targetValue)) {
49
+ result[key] = { ...targetValue, ...sourceValue };
50
+ } else if (sourceValue !== void 0) {
51
+ result[key] = sourceValue;
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+ function loadConfig() {
57
+ const configPaths = [
58
+ path.join(process.cwd(), ".claude-limitline.json"),
59
+ path.join(os.homedir(), ".claude-limitline.json"),
60
+ path.join(os.homedir(), ".config", "claude-limitline", "config.json")
61
+ ];
62
+ for (const configPath of configPaths) {
63
+ try {
64
+ if (fs.existsSync(configPath)) {
65
+ const content = fs.readFileSync(configPath, "utf-8");
66
+ const userConfig = JSON.parse(content);
67
+ debug(`Loaded config from ${configPath}`);
68
+ return deepMerge(DEFAULT_CONFIG, userConfig);
69
+ }
70
+ } catch (error) {
71
+ debug(`Failed to load config from ${configPath}:`, error);
72
+ }
73
+ }
74
+ debug("Using default config");
75
+ return DEFAULT_CONFIG;
76
+ }
77
+
78
+ // src/utils/oauth.ts
79
+ import { exec } from "child_process";
80
+ import { promisify } from "util";
81
+ import fs2 from "fs";
82
+ import path2 from "path";
83
+ import os2 from "os";
84
+ var execAsync = promisify(exec);
85
+ async function getOAuthTokenWindows() {
86
+ try {
87
+ const { stdout } = await execAsync(
88
+ `powershell -Command "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String((Get-StoredCredential -Target 'Claude Code' -AsCredentialObject).Password))"`,
89
+ { timeout: 5e3 }
90
+ );
91
+ const token = stdout.trim();
92
+ if (token && token.startsWith("sk-ant-oat")) {
93
+ return token;
94
+ }
95
+ } catch (error) {
96
+ debug("PowerShell credential retrieval failed:", error);
97
+ }
98
+ try {
99
+ const { stdout } = await execAsync(
100
+ `powershell -Command "$cred = cmdkey /list:Claude* | Select-String -Pattern 'User:.*'; if ($cred) { $cred.Line.Split(':')[1].Trim() }"`,
101
+ { timeout: 5e3 }
102
+ );
103
+ debug("cmdkey output:", stdout);
104
+ } catch (error) {
105
+ debug("cmdkey approach failed:", error);
106
+ }
107
+ const primaryPath = path2.join(os2.homedir(), ".claude", ".credentials.json");
108
+ try {
109
+ if (fs2.existsSync(primaryPath)) {
110
+ const content = fs2.readFileSync(primaryPath, "utf-8");
111
+ const config = JSON.parse(content);
112
+ if (config.claudeAiOauth && typeof config.claudeAiOauth === "object") {
113
+ const token = config.claudeAiOauth.accessToken;
114
+ if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
115
+ debug(`Found OAuth token in ${primaryPath} under claudeAiOauth.accessToken`);
116
+ return token;
117
+ }
118
+ }
119
+ }
120
+ } catch (error) {
121
+ debug(`Failed to read config from ${primaryPath}:`, error);
122
+ }
123
+ const fallbackPaths = [
124
+ path2.join(os2.homedir(), ".claude", "credentials.json"),
125
+ path2.join(os2.homedir(), ".config", "claude-code", "credentials.json"),
126
+ path2.join(process.env.APPDATA || "", "Claude Code", "credentials.json"),
127
+ path2.join(process.env.LOCALAPPDATA || "", "Claude Code", "credentials.json")
128
+ ];
129
+ for (const configPath of fallbackPaths) {
130
+ try {
131
+ if (fs2.existsSync(configPath)) {
132
+ const content = fs2.readFileSync(configPath, "utf-8");
133
+ const config = JSON.parse(content);
134
+ for (const key of ["oauth_token", "token", "accessToken"]) {
135
+ const token = config[key];
136
+ if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
137
+ debug(`Found OAuth token in ${configPath} under key ${key}`);
138
+ return token;
139
+ }
140
+ }
141
+ }
142
+ } catch (error) {
143
+ debug(`Failed to read config from ${configPath}:`, error);
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ async function getOAuthTokenMacOS() {
149
+ try {
150
+ const { stdout } = await execAsync(
151
+ `security find-generic-password -s "Claude Code" -w`,
152
+ { timeout: 5e3 }
153
+ );
154
+ const token = stdout.trim();
155
+ if (token && token.startsWith("sk-ant-oat")) {
156
+ return token;
157
+ }
158
+ } catch (error) {
159
+ debug("macOS Keychain retrieval failed:", error);
160
+ }
161
+ return null;
162
+ }
163
+ async function getOAuthTokenLinux() {
164
+ try {
165
+ const { stdout } = await execAsync(
166
+ `secret-tool lookup service "Claude Code"`,
167
+ { timeout: 5e3 }
168
+ );
169
+ const token = stdout.trim();
170
+ if (token && token.startsWith("sk-ant-oat")) {
171
+ return token;
172
+ }
173
+ } catch (error) {
174
+ debug("Linux secret-tool retrieval failed:", error);
175
+ }
176
+ const configPaths = [
177
+ path2.join(os2.homedir(), ".claude", ".credentials.json"),
178
+ path2.join(os2.homedir(), ".claude", "credentials.json"),
179
+ path2.join(os2.homedir(), ".config", "claude-code", "credentials.json")
180
+ ];
181
+ for (const configPath of configPaths) {
182
+ try {
183
+ if (fs2.existsSync(configPath)) {
184
+ const content = fs2.readFileSync(configPath, "utf-8");
185
+ const config = JSON.parse(content);
186
+ if (config.claudeAiOauth && typeof config.claudeAiOauth === "object") {
187
+ const token = config.claudeAiOauth.accessToken;
188
+ if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
189
+ debug(`Found OAuth token in ${configPath} under claudeAiOauth.accessToken`);
190
+ return token;
191
+ }
192
+ }
193
+ for (const key of ["oauth_token", "token", "accessToken"]) {
194
+ const token = config[key];
195
+ if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
196
+ debug(`Found OAuth token in ${configPath} under key ${key}`);
197
+ return token;
198
+ }
199
+ }
200
+ }
201
+ } catch (error) {
202
+ debug(`Failed to read config from ${configPath}:`, error);
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+ async function getOAuthToken() {
208
+ const platform = process.platform;
209
+ debug(`Attempting to retrieve OAuth token on platform: ${platform}`);
210
+ switch (platform) {
211
+ case "win32":
212
+ return getOAuthTokenWindows();
213
+ case "darwin":
214
+ return getOAuthTokenMacOS();
215
+ case "linux":
216
+ return getOAuthTokenLinux();
217
+ default:
218
+ debug(`Unsupported platform for OAuth token retrieval: ${platform}`);
219
+ return null;
220
+ }
221
+ }
222
+ async function fetchUsageFromAPI(token) {
223
+ try {
224
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
225
+ method: "GET",
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ "User-Agent": "claude-limitline/1.0.0",
229
+ Authorization: `Bearer ${token}`,
230
+ "anthropic-beta": "oauth-2025-04-20"
231
+ }
232
+ });
233
+ if (!response.ok) {
234
+ debug(`Usage API returned status ${response.status}: ${response.statusText}`);
235
+ return null;
236
+ }
237
+ const data = await response.json();
238
+ debug("Usage API response:", JSON.stringify(data));
239
+ const parseUsageBlock = (block) => {
240
+ if (!block) return null;
241
+ return {
242
+ resetAt: block.resets_at ? new Date(block.resets_at) : /* @__PURE__ */ new Date(),
243
+ percentUsed: block.utilization ?? 0,
244
+ isOverLimit: (block.utilization ?? 0) >= 100
245
+ };
246
+ };
247
+ return {
248
+ fiveHour: parseUsageBlock(data.five_hour),
249
+ sevenDay: parseUsageBlock(data.seven_day),
250
+ raw: data
251
+ };
252
+ } catch (error) {
253
+ debug("Failed to fetch usage from API:", error);
254
+ return null;
255
+ }
256
+ }
257
+ var cachedUsage = null;
258
+ var cacheTimestamp = 0;
259
+ var cachedToken = null;
260
+ async function getRealtimeUsage(pollIntervalMinutes = 15) {
261
+ const now = Date.now();
262
+ const cacheAgeMs = now - cacheTimestamp;
263
+ const pollIntervalMs = pollIntervalMinutes * 60 * 1e3;
264
+ if (cachedUsage && cacheAgeMs < pollIntervalMs) {
265
+ debug(`Using cached usage data (age: ${Math.round(cacheAgeMs / 1e3)}s)`);
266
+ return cachedUsage;
267
+ }
268
+ if (!cachedToken) {
269
+ cachedToken = await getOAuthToken();
270
+ if (!cachedToken) {
271
+ debug("Could not retrieve OAuth token for realtime usage");
272
+ return null;
273
+ }
274
+ }
275
+ const usage = await fetchUsageFromAPI(cachedToken);
276
+ if (usage) {
277
+ cachedUsage = usage;
278
+ cacheTimestamp = now;
279
+ debug("Refreshed realtime usage cache");
280
+ } else {
281
+ cachedToken = null;
282
+ }
283
+ return usage;
284
+ }
285
+
286
+ // src/segments/block.ts
287
+ var BlockProvider = class {
288
+ async getBlockInfo(pollInterval) {
289
+ const realtimeInfo = await this.getRealtimeBlockInfo(pollInterval);
290
+ if (realtimeInfo) {
291
+ return realtimeInfo;
292
+ }
293
+ debug("Realtime mode failed, no block data available");
294
+ return {
295
+ percentUsed: null,
296
+ resetAt: null,
297
+ timeRemaining: null,
298
+ isRealtime: false
299
+ };
300
+ }
301
+ async getRealtimeBlockInfo(pollInterval) {
302
+ try {
303
+ const usage = await getRealtimeUsage(pollInterval ?? 15);
304
+ if (!usage || !usage.fiveHour) {
305
+ debug("No realtime block usage data available");
306
+ return null;
307
+ }
308
+ const fiveHour = usage.fiveHour;
309
+ const now = /* @__PURE__ */ new Date();
310
+ const resetAt = new Date(fiveHour.resetAt);
311
+ const timeRemaining = Math.max(
312
+ 0,
313
+ Math.round((resetAt.getTime() - now.getTime()) / (1e3 * 60))
314
+ );
315
+ debug(
316
+ `Block segment (realtime): ${fiveHour.percentUsed}% used, resets at ${fiveHour.resetAt.toISOString()}, ${timeRemaining}m remaining`
317
+ );
318
+ return {
319
+ percentUsed: fiveHour.percentUsed,
320
+ resetAt: fiveHour.resetAt,
321
+ timeRemaining,
322
+ isRealtime: true
323
+ };
324
+ } catch (error) {
325
+ debug("Error getting realtime block info:", error);
326
+ return null;
327
+ }
328
+ }
329
+ };
330
+
331
+ // src/segments/weekly.ts
332
+ var WeeklyProvider = class {
333
+ calculateWeekProgress(resetDay, resetHour, resetMinute) {
334
+ const now = /* @__PURE__ */ new Date();
335
+ const dayOfWeek = now.getDay();
336
+ const hours = now.getHours();
337
+ const minutes = now.getMinutes();
338
+ const targetDay = resetDay ?? 1;
339
+ const targetHour = resetHour ?? 0;
340
+ const targetMinute = resetMinute ?? 0;
341
+ let daysSinceReset = (dayOfWeek - targetDay + 7) % 7;
342
+ if (daysSinceReset === 0) {
343
+ const currentMinutes = hours * 60 + minutes;
344
+ const resetMinutes = targetHour * 60 + targetMinute;
345
+ if (currentMinutes < resetMinutes) {
346
+ daysSinceReset = 7;
347
+ }
348
+ }
349
+ const hoursIntoWeek = daysSinceReset * 24 + hours - targetHour + (minutes - targetMinute) / 60;
350
+ const totalHoursInWeek = 7 * 24;
351
+ const progress = Math.max(0, Math.min(100, hoursIntoWeek / totalHoursInWeek * 100));
352
+ return Math.round(progress);
353
+ }
354
+ calculateWeekProgressFromResetTime(resetAt) {
355
+ const now = /* @__PURE__ */ new Date();
356
+ const resetTime = new Date(resetAt);
357
+ const periodStart = new Date(resetTime);
358
+ periodStart.setDate(periodStart.getDate() - 7);
359
+ if (now > resetTime) {
360
+ const newPeriodStart = resetTime;
361
+ const newResetTime = new Date(resetTime);
362
+ newResetTime.setDate(newResetTime.getDate() + 7);
363
+ const totalMs2 = newResetTime.getTime() - newPeriodStart.getTime();
364
+ const elapsedMs2 = now.getTime() - newPeriodStart.getTime();
365
+ return Math.round(elapsedMs2 / totalMs2 * 100);
366
+ }
367
+ const totalMs = resetTime.getTime() - periodStart.getTime();
368
+ const elapsedMs = now.getTime() - periodStart.getTime();
369
+ const progress = Math.max(0, Math.min(100, elapsedMs / totalMs * 100));
370
+ return Math.round(progress);
371
+ }
372
+ async getWeeklyInfo(resetDay, resetHour, resetMinute, pollInterval) {
373
+ const realtimeInfo = await this.getRealtimeWeeklyInfo(pollInterval);
374
+ if (realtimeInfo) {
375
+ return realtimeInfo;
376
+ }
377
+ debug("Realtime mode failed, falling back to estimate mode");
378
+ const weekProgressPercent = this.calculateWeekProgress(resetDay, resetHour, resetMinute);
379
+ return {
380
+ percentUsed: null,
381
+ resetAt: null,
382
+ isRealtime: false,
383
+ weekProgressPercent
384
+ };
385
+ }
386
+ async getRealtimeWeeklyInfo(pollInterval) {
387
+ try {
388
+ const usage = await getRealtimeUsage(pollInterval ?? 15);
389
+ if (!usage || !usage.sevenDay) {
390
+ debug("No realtime weekly usage data available");
391
+ return null;
392
+ }
393
+ const sevenDay = usage.sevenDay;
394
+ const weekProgressPercent = this.calculateWeekProgressFromResetTime(sevenDay.resetAt);
395
+ debug(
396
+ `Weekly segment (realtime): ${sevenDay.percentUsed}% used, resets at ${sevenDay.resetAt.toISOString()}`
397
+ );
398
+ return {
399
+ percentUsed: sevenDay.percentUsed,
400
+ resetAt: sevenDay.resetAt,
401
+ isRealtime: true,
402
+ weekProgressPercent
403
+ };
404
+ } catch (error) {
405
+ debug("Error getting realtime weekly info:", error);
406
+ return null;
407
+ }
408
+ }
409
+ };
410
+
411
+ // src/utils/constants.ts
412
+ var SYMBOLS = {
413
+ right: "\uE0B0",
414
+ left: "\uE0B2",
415
+ branch: "\uE0A0",
416
+ separator: "\uE0B1",
417
+ block_cost: "\uF252",
418
+ // Hourglass
419
+ weekly_cost: "\uF073",
420
+ // Calendar
421
+ progress_full: "\u2588",
422
+ // Full block
423
+ progress_empty: "\u2591"
424
+ // Light shade
425
+ };
426
+ var TEXT_SYMBOLS = {
427
+ right: ">",
428
+ left: "<",
429
+ branch: "",
430
+ separator: "|",
431
+ block_cost: "BLK",
432
+ weekly_cost: "WK",
433
+ progress_full: "#",
434
+ progress_empty: "-"
435
+ };
436
+ var RESET_CODE = "\x1B[0m";
437
+
438
+ // src/themes/index.ts
439
+ var color = (n) => `\x1B[38;5;${n}m`;
440
+ var bgColor = (n) => `\x1B[48;5;${n}m`;
441
+ var themes = {
442
+ dark: {
443
+ blockBg: bgColor(236),
444
+ blockFg: color(252),
445
+ weeklyBg: bgColor(236),
446
+ weeklyFg: color(252),
447
+ warningBg: bgColor(172),
448
+ warningFg: color(232),
449
+ criticalBg: bgColor(160),
450
+ criticalFg: color(255),
451
+ progressFull: color(76),
452
+ progressEmpty: color(240),
453
+ separatorFg: color(244)
454
+ },
455
+ light: {
456
+ blockBg: bgColor(254),
457
+ blockFg: color(236),
458
+ weeklyBg: bgColor(254),
459
+ weeklyFg: color(236),
460
+ warningBg: bgColor(214),
461
+ warningFg: color(232),
462
+ criticalBg: bgColor(196),
463
+ criticalFg: color(255),
464
+ progressFull: color(34),
465
+ progressEmpty: color(250),
466
+ separatorFg: color(244)
467
+ },
468
+ nord: {
469
+ blockBg: bgColor(236),
470
+ blockFg: color(110),
471
+ weeklyBg: bgColor(236),
472
+ weeklyFg: color(110),
473
+ warningBg: bgColor(179),
474
+ warningFg: color(232),
475
+ criticalBg: bgColor(131),
476
+ criticalFg: color(255),
477
+ progressFull: color(108),
478
+ progressEmpty: color(239),
479
+ separatorFg: color(60)
480
+ },
481
+ gruvbox: {
482
+ blockBg: bgColor(237),
483
+ blockFg: color(223),
484
+ weeklyBg: bgColor(237),
485
+ weeklyFg: color(223),
486
+ warningBg: bgColor(214),
487
+ warningFg: color(235),
488
+ criticalBg: bgColor(167),
489
+ criticalFg: color(235),
490
+ progressFull: color(142),
491
+ progressEmpty: color(239),
492
+ separatorFg: color(246)
493
+ }
494
+ };
495
+ function getTheme(name) {
496
+ return themes[name] || themes.dark;
497
+ }
498
+
499
+ // src/renderer.ts
500
+ var Renderer = class {
501
+ config;
502
+ theme;
503
+ symbols;
504
+ constructor(config) {
505
+ this.config = config;
506
+ this.theme = getTheme(config.theme || "dark");
507
+ const useNerd = config.display?.useNerdFonts ?? true;
508
+ const symbolSet = useNerd ? SYMBOLS : TEXT_SYMBOLS;
509
+ this.symbols = {
510
+ block: symbolSet.block_cost,
511
+ weekly: symbolSet.weekly_cost,
512
+ separator: symbolSet.separator,
513
+ progressFull: symbolSet.progress_full,
514
+ progressEmpty: symbolSet.progress_empty
515
+ };
516
+ }
517
+ formatProgressBar(percent, width, colors) {
518
+ const filled = Math.round(percent / 100 * width);
519
+ const empty = width - filled;
520
+ const filledBar = colors.progressFull + this.symbols.progressFull.repeat(filled);
521
+ const emptyBar = colors.progressEmpty + this.symbols.progressEmpty.repeat(empty);
522
+ return filledBar + emptyBar + RESET_CODE;
523
+ }
524
+ formatTimeRemaining(minutes) {
525
+ if (minutes >= 60) {
526
+ const hours = Math.floor(minutes / 60);
527
+ const mins = minutes % 60;
528
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
529
+ }
530
+ return `${minutes}m`;
531
+ }
532
+ getColorForPercent(percent, colors) {
533
+ const threshold = this.config.budget?.warningThreshold ?? 80;
534
+ if (percent >= 100) {
535
+ return { bg: colors.criticalBg, fg: colors.criticalFg };
536
+ } else if (percent >= threshold) {
537
+ return { bg: colors.warningBg, fg: colors.warningFg };
538
+ }
539
+ return { bg: colors.blockBg, fg: colors.blockFg };
540
+ }
541
+ renderBlock(blockInfo) {
542
+ if (!blockInfo || !this.config.block?.enabled) {
543
+ return "";
544
+ }
545
+ if (blockInfo.percentUsed === null) {
546
+ return `${this.symbols.block} --`;
547
+ }
548
+ const percent = blockInfo.percentUsed;
549
+ const displayStyle = this.config.block.displayStyle || "bar";
550
+ const barWidth = this.config.block.barWidth || 10;
551
+ const showTime = this.config.block.showTimeRemaining ?? true;
552
+ let display;
553
+ if (displayStyle === "bar") {
554
+ const bar = this.formatProgressBar(percent, barWidth, this.theme);
555
+ display = `${bar} ${Math.round(percent)}%`;
556
+ } else {
557
+ display = `${Math.round(percent)}%`;
558
+ }
559
+ if (showTime && blockInfo.timeRemaining !== null) {
560
+ const timeStr = this.formatTimeRemaining(blockInfo.timeRemaining);
561
+ display += ` (${timeStr} left)`;
562
+ }
563
+ return `${this.symbols.block} ${display}`;
564
+ }
565
+ renderWeekly(weeklyInfo) {
566
+ if (!weeklyInfo || !this.config.weekly?.enabled) {
567
+ return "";
568
+ }
569
+ if (weeklyInfo.percentUsed === null) {
570
+ return `${this.symbols.weekly} --`;
571
+ }
572
+ const percent = weeklyInfo.percentUsed;
573
+ const displayStyle = this.config.weekly.displayStyle || "bar";
574
+ const barWidth = this.config.weekly.barWidth || 10;
575
+ const showWeekProgress = this.config.weekly.showWeekProgress ?? true;
576
+ let display;
577
+ if (displayStyle === "bar") {
578
+ const bar = this.formatProgressBar(percent, barWidth, this.theme);
579
+ display = `${bar} ${Math.round(percent)}%`;
580
+ } else {
581
+ display = `${Math.round(percent)}%`;
582
+ }
583
+ if (showWeekProgress) {
584
+ display += ` (wk ${weeklyInfo.weekProgressPercent}%)`;
585
+ }
586
+ return `${this.symbols.weekly} ${display}`;
587
+ }
588
+ render(blockInfo, weeklyInfo) {
589
+ const parts = [];
590
+ const blockSegment = this.renderBlock(blockInfo);
591
+ if (blockSegment) {
592
+ parts.push(blockSegment);
593
+ }
594
+ const weeklySegment = this.renderWeekly(weeklyInfo);
595
+ if (weeklySegment) {
596
+ parts.push(weeklySegment);
597
+ }
598
+ if (parts.length === 0) {
599
+ return "";
600
+ }
601
+ const separator = ` ${this.theme.separatorFg}${this.symbols.separator}${RESET_CODE} `;
602
+ return parts.join(separator);
603
+ }
604
+ };
605
+
606
+ // src/index.ts
607
+ async function main() {
608
+ try {
609
+ const config = loadConfig();
610
+ debug("Config loaded:", JSON.stringify(config));
611
+ const blockProvider = new BlockProvider();
612
+ const weeklyProvider = new WeeklyProvider();
613
+ const pollInterval = config.budget?.pollInterval ?? 15;
614
+ const [blockInfo, weeklyInfo] = await Promise.all([
615
+ config.block?.enabled ? blockProvider.getBlockInfo(pollInterval) : null,
616
+ config.weekly?.enabled ? weeklyProvider.getWeeklyInfo(
617
+ config.budget?.resetDay,
618
+ config.budget?.resetHour,
619
+ config.budget?.resetMinute,
620
+ pollInterval
621
+ ) : null
622
+ ]);
623
+ debug("Block info:", JSON.stringify(blockInfo));
624
+ debug("Weekly info:", JSON.stringify(weeklyInfo));
625
+ const renderer = new Renderer(config);
626
+ const output = renderer.render(blockInfo, weeklyInfo);
627
+ if (output) {
628
+ process.stdout.write(output);
629
+ }
630
+ } catch (error) {
631
+ debug("Error in main:", error);
632
+ process.exit(0);
633
+ }
634
+ }
635
+ main();
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "claude-limitline",
3
+ "version": "1.0.0",
4
+ "description": "A statusline for Claude Code showing real-time usage limits and weekly tracking",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "claude-limitline": "dist/index.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "build": "tsup src/index.ts --format esm --dts --clean",
12
+ "dev": "tsup src/index.ts --format esm --watch",
13
+ "start": "node dist/index.js",
14
+ "typecheck": "tsc --noEmit",
15
+ "lint": "eslint src/",
16
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/tylergraydev/claude-limitline.git"
21
+ },
22
+ "keywords": [
23
+ "claude",
24
+ "claude-code",
25
+ "statusline",
26
+ "powerline",
27
+ "usage",
28
+ "monitoring",
29
+ "cli",
30
+ "anthropic"
31
+ ],
32
+ "author": "Tyler Gray",
33
+ "license": "MIT",
34
+ "bugs": {
35
+ "url": "https://github.com/tylergraydev/claude-limitline/issues"
36
+ },
37
+ "homepage": "https://github.com/tylergraydev/claude-limitline#readme",
38
+ "devDependencies": {
39
+ "@types/node": "^20.10.0",
40
+ "tsup": "^8.0.0",
41
+ "typescript": "^5.3.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ]
49
+ }