claude-limit-statusline 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/bin/cli.js +364 -0
  4. package/package.json +42 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ann0nip
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,157 @@
1
+ # claude-limit-statusline
2
+
3
+ A [Claude Code](https://code.claude.com/docs) status line that shows your **real
4
+ subscription limits** — the 5‑hour session window and the 7‑day weekly window —
5
+ with a **live reset countdown**.
6
+
7
+ ```
8
+ 🤖 Opus 4.8 (1M context) | 🧠 42k (4%) | ⏳ Session 17% · resets in 0h47m (23:12) | 📅 Week 10% · resets in 2d 21h (Jun 03 19:54)
9
+ ```
10
+
11
+ Unlike tools that estimate the 5‑hour block from local logs, this reads the
12
+ **official `rate_limits` payload** that Claude Code provides on stdin — the same
13
+ numbers you see when you run `/usage`. No guessing, no rounding to the hour.
14
+
15
+ ## Who is this for?
16
+
17
+ This shows the **subscription rate limits** that Anthropic exposes only to
18
+ **Claude.ai Pro/Max** users. If you use the **pay‑as‑you‑go API**, the
19
+ `rate_limits` field is not present — you probably want a cost tracker like
20
+ [`ccusage`](https://github.com/ryoppippi/ccusage) instead.
21
+
22
+ | | Pro/Max subscription | Pay‑as‑you‑go API |
23
+ | --- | --- | --- |
24
+ | `rate_limits` in status line | ✅ yes | ❌ no |
25
+ | What this tool shows | 5h + 7d limit % and reset | — |
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install -g claude-limit-statusline
31
+ cc-limits --install
32
+ ```
33
+
34
+ That's it. `--install` writes the `statusLine` entry into `~/.claude/settings.json`
35
+ for you (merging, never clobbering your other settings). Then open a **new**
36
+ Claude Code session and send one message — `rate_limits` populates after the
37
+ first API response.
38
+
39
+ Want only the two limits? Pass your display options straight through:
40
+
41
+ ```bash
42
+ cc-limits --install --segments=session,week
43
+ ```
44
+
45
+ To remove it again:
46
+
47
+ ```bash
48
+ cc-limits --uninstall
49
+ ```
50
+
51
+ > **Why `--install` instead of editing by hand?** It records an **absolute**
52
+ > `node` + script path, so it works even under nvm/Volta where a globally
53
+ > installed command isn't on the `PATH` of the non‑login shell Claude Code uses
54
+ > for the status line.
55
+
56
+ <details>
57
+ <summary>Manual setup (if you prefer)</summary>
58
+
59
+ Add this to `~/.claude/settings.json`:
60
+
61
+ ```json
62
+ {
63
+ "statusLine": {
64
+ "type": "command",
65
+ "command": "cc-limits"
66
+ }
67
+ }
68
+ ```
69
+
70
+ If the bar stays blank (nvm/Volta `PATH` issue), use absolute paths instead —
71
+ `"command": "/path/to/node /path/to/cli.js"` (find them with `which node` and
72
+ `npm root -g`), which is exactly what `cc-limits --install` does automatically.
73
+
74
+ </details>
75
+
76
+ ## Output
77
+
78
+ | Segment | Meaning | Source |
79
+ | --- | --- | --- |
80
+ | `🤖 model` | Active model | local |
81
+ | `🧠 42k (4%)` | Tokens in the current context window | local |
82
+ | `⏳ Session 17% · resets in 0h47m (23:12)` | **Real** 5‑hour limit used + reset | server |
83
+ | `📅 Week 10% · resets in 2d 21h (Jun 03 19:54)` | **Real** 7‑day limit used + reset | server |
84
+
85
+ The percentage **is** your "how close am I to the limit" gauge. Subscription
86
+ limits are dynamic, so Anthropic does not expose a fixed token cap — only a
87
+ percentage, which is exactly what this surfaces.
88
+
89
+ Before the first API response (and right after `/compact`) the session segment
90
+ shows `⏳ Session --` until fresh data arrives.
91
+
92
+ ## Configuration
93
+
94
+ Pick **which segments** to show (and their order). The four segments are
95
+ `model`, `context`, `session`, `week`.
96
+
97
+ ```jsonc
98
+ // Only the two limits, nothing else:
99
+ "command": "cc-limits --segments=session,week"
100
+
101
+ // Everything except the context tokens:
102
+ "command": "cc-limits --no-context"
103
+ ```
104
+
105
+ Pick **which reset countdowns** to show:
106
+
107
+ ```jsonc
108
+ "command": "cc-limits --reset=session" // session reset only
109
+ "command": "cc-limits --no-reset" // just percentages, no countdowns
110
+ ```
111
+
112
+ ### Flags
113
+
114
+ | Flag | Description |
115
+ | --- | --- |
116
+ | `--segments=a,b,c` | Allowlist + order. Subset of `model,context,session,week` |
117
+ | `--no-<segment>` | Hide one segment (e.g. `--no-context`). Repeatable |
118
+ | `--reset=both\|session\|week\|none` | Which reset countdowns to show (default `both`) |
119
+ | `--no-reset` | Shorthand for `--reset=none` |
120
+ | `--no-color` | Disable ANSI colors |
121
+ | `--demo` | Print a sample line (no stdin needed) |
122
+ | `-h`, `--help` | Show help |
123
+
124
+ ### Environment variables
125
+
126
+ Equivalent to the flags, handy if you don't want to edit the command string:
127
+
128
+ | Env var | Default | Description |
129
+ | --- | --- | --- |
130
+ | `CC_LIMITS_SEGMENTS` | `model,context,session,week` | Segments + order |
131
+ | `CC_LIMITS_RESET` | `both` | `both` / `session` / `week` / `none` |
132
+ | `CC_LIMITS_WARN` | `70` | % at/above which a limit turns yellow |
133
+ | `CC_LIMITS_CRIT` | `90` | % at/above which a limit turns red |
134
+ | `CC_LIMITS_SEP` | `" \| "` | Separator between segments |
135
+ | `NO_COLOR` | — | Set to disable ANSI colors |
136
+
137
+ ```bash
138
+ cc-limits --demo
139
+ cc-limits --segments=session,week --reset=session --demo
140
+ ```
141
+
142
+ ## How it works
143
+
144
+ Claude Code runs your status-line command on every update and pipes a JSON
145
+ [status-line payload](https://code.claude.com/docs/en/statusline) to stdin. This
146
+ program parses it and reads:
147
+
148
+ - `rate_limits.five_hour.used_percentage` / `.resets_at`
149
+ - `rate_limits.seven_day.used_percentage` / `.resets_at`
150
+ - `context_window.*` for the token/context segment
151
+
152
+ `resets_at` is Unix epoch seconds; the countdown is computed against the current
153
+ time. Everything runs with **zero dependencies** for fast startup.
154
+
155
+ ## License
156
+
157
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * claude-limit-statusline
6
+ *
7
+ * A Claude Code status line that shows your REAL subscription rate limits
8
+ * (5-hour session + 7-day weekly) with a live reset countdown.
9
+ *
10
+ * Claude Code pipes a JSON payload on stdin. For Claude.ai Pro/Max
11
+ * subscribers it contains a `rate_limits` object sourced from Anthropic's
12
+ * servers — the same numbers you see in `/usage`. This reads those fields
13
+ * and prints a single status line. It does NOT estimate locally.
14
+ *
15
+ * Docs: https://code.claude.com/docs/en/statusline
16
+ */
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const os = require("os");
21
+
22
+ const argv = process.argv.slice(2);
23
+
24
+ // ---------- arg / env helpers ----------
25
+ function getFlagValue(key) {
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === key) {
29
+ const next = argv[i + 1];
30
+ return next && !next.startsWith("--") ? next : "";
31
+ }
32
+ if (a.startsWith(key + "=")) return a.slice(key.length + 1);
33
+ }
34
+ return undefined;
35
+ }
36
+ function parseList(val) {
37
+ return String(val)
38
+ .split(",")
39
+ .map((s) => s.trim().toLowerCase())
40
+ .filter(Boolean);
41
+ }
42
+ function numEnv(name, def) {
43
+ const v = Number(process.env[name]);
44
+ return Number.isFinite(v) ? v : def;
45
+ }
46
+
47
+ // ---------- config ----------
48
+ const ALL_SEGMENTS = ["model", "context", "session", "week"];
49
+
50
+ // Which segments to show. --segments / CC_LIMITS_SEGMENTS = allowlist (and order).
51
+ // Otherwise the full set minus any --no-<segment> flags.
52
+ let SEGMENTS;
53
+ const segSel = getFlagValue("--segments") || process.env.CC_LIMITS_SEGMENTS;
54
+ if (segSel != null && segSel !== "") {
55
+ SEGMENTS = parseList(segSel).filter((s) => ALL_SEGMENTS.includes(s));
56
+ } else {
57
+ SEGMENTS = ALL_SEGMENTS.filter((s) => !argv.includes(`--no-${s}`));
58
+ }
59
+
60
+ // Which reset countdowns to show: both | session | week | none.
61
+ let RESET_MODE = (
62
+ getFlagValue("--reset") ||
63
+ process.env.CC_LIMITS_RESET ||
64
+ "both"
65
+ ).toLowerCase();
66
+ if (argv.includes("--no-reset")) RESET_MODE = "none";
67
+ function showReset(which) {
68
+ return RESET_MODE === "both" || RESET_MODE === which;
69
+ }
70
+
71
+ const WARN_PCT = numEnv("CC_LIMITS_WARN", 70);
72
+ const CRIT_PCT = numEnv("CC_LIMITS_CRIT", 90);
73
+ const SEP = process.env.CC_LIMITS_SEP || " | ";
74
+ const NO_COLOR =
75
+ argv.includes("--no-color") ||
76
+ (process.env.NO_COLOR != null && process.env.NO_COLOR !== "");
77
+
78
+ // ---------- colors ----------
79
+ const C = {
80
+ reset: "\x1b[0m",
81
+ dim: "\x1b[2m",
82
+ red: "\x1b[31m",
83
+ green: "\x1b[32m",
84
+ yellow: "\x1b[33m",
85
+ cyan: "\x1b[36m",
86
+ gray: "\x1b[90m",
87
+ };
88
+ function paint(s, color) {
89
+ if (NO_COLOR || !color) return s;
90
+ return color + s + C.reset;
91
+ }
92
+ function pctColor(p) {
93
+ if (p >= CRIT_PCT) return C.red;
94
+ if (p >= WARN_PCT) return C.yellow;
95
+ return C.green;
96
+ }
97
+
98
+ // ---------- formatters ----------
99
+ const MON = [
100
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
101
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
102
+ ];
103
+ function pad(n) {
104
+ return String(n).padStart(2, "0");
105
+ }
106
+ // Strip control chars / ANSI escapes from untrusted string fields before
107
+ // printing them to the terminal.
108
+ function clean(s) {
109
+ return String(s).replace(/[\x00-\x1f\x7f]/g, "");
110
+ }
111
+ function round(n) {
112
+ return Math.round(Number(n));
113
+ }
114
+ function humanTokens(n) {
115
+ n = Number(n) || 0;
116
+ if (n >= 1000) return Math.round(n / 1000) + "k";
117
+ return String(n);
118
+ }
119
+ function fmtCountdown(epochSec) {
120
+ let diff = Math.floor(epochSec - Date.now() / 1000);
121
+ if (diff < 0) diff = 0;
122
+ const d = Math.floor(diff / 86400);
123
+ const h = Math.floor((diff % 86400) / 3600);
124
+ const m = Math.floor((diff % 3600) / 60);
125
+ if (d > 0) return `${d}d ${h}h`;
126
+ return `${h}h${pad(m)}m`;
127
+ }
128
+ function fmtClock(epochSec, withDate) {
129
+ const d = new Date(epochSec * 1000);
130
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
131
+ if (withDate) return `${MON[d.getMonth()]} ${pad(d.getDate())} ${time}`;
132
+ return time;
133
+ }
134
+
135
+ // ---------- segment renderers ----------
136
+ function renderModel(data) {
137
+ const model = clean(data?.model?.display_name || "Claude");
138
+ return paint("🤖 " + model, C.cyan);
139
+ }
140
+ function renderContext(data) {
141
+ const cw = data?.context_window || {};
142
+ const tokens =
143
+ (Number(cw.total_input_tokens) || 0) +
144
+ (Number(cw.total_output_tokens) || 0);
145
+ let s = "🧠 " + humanTokens(tokens);
146
+ if (cw.used_percentage != null) s += ` (${round(cw.used_percentage)}%)`;
147
+ return paint(s, C.gray);
148
+ }
149
+ function renderLimit(limit, { icon, label, which, withDate }) {
150
+ if (!limit || limit.used_percentage == null) {
151
+ return paint(`${icon} ${label} --`, C.dim);
152
+ }
153
+ const p = round(limit.used_percentage);
154
+ let s = `${icon} ${label} ${paint(p + "%", pctColor(p))}`;
155
+ if (limit.resets_at > 0 && showReset(which)) {
156
+ s += paint(
157
+ ` · resets in ${fmtCountdown(limit.resets_at)} (${fmtClock(
158
+ limit.resets_at,
159
+ withDate
160
+ )})`,
161
+ C.dim
162
+ );
163
+ }
164
+ return s;
165
+ }
166
+
167
+ function render(data) {
168
+ const rl = data?.rate_limits || {};
169
+ const out = [];
170
+ for (const seg of SEGMENTS) {
171
+ if (seg === "model") out.push(renderModel(data));
172
+ else if (seg === "context") out.push(renderContext(data));
173
+ else if (seg === "session")
174
+ out.push(
175
+ renderLimit(rl.five_hour, {
176
+ icon: "⏳",
177
+ label: "Session",
178
+ which: "session",
179
+ withDate: false,
180
+ })
181
+ );
182
+ else if (seg === "week")
183
+ out.push(
184
+ renderLimit(rl.seven_day, {
185
+ icon: "📅",
186
+ label: "Week",
187
+ which: "week",
188
+ withDate: true,
189
+ })
190
+ );
191
+ }
192
+ return out.join(SEP);
193
+ }
194
+
195
+ // ---------- demo payload ----------
196
+ function demoPayload() {
197
+ const now = Math.floor(Date.now() / 1000);
198
+ return {
199
+ model: { display_name: "Opus 4.8 (1M context)" },
200
+ context_window: {
201
+ used_percentage: 4,
202
+ total_input_tokens: 40000,
203
+ total_output_tokens: 2328,
204
+ },
205
+ rate_limits: {
206
+ five_hour: { used_percentage: 17, resets_at: now + 2880 },
207
+ seven_day: { used_percentage: 10, resets_at: now + 250000 },
208
+ },
209
+ };
210
+ }
211
+
212
+ // ---------- main ----------
213
+ function out(line) {
214
+ process.stdout.write(line + "\n");
215
+ }
216
+
217
+ if (argv.includes("--help") || argv.includes("-h")) {
218
+ out(
219
+ [
220
+ "claude-limit-statusline (cc-limits)",
221
+ "",
222
+ "Reads Claude Code's JSON status-line payload on stdin and prints your",
223
+ "real Pro/Max rate limits (5h session + 7d week) with reset countdowns.",
224
+ "",
225
+ "Setup (writes ~/.claude/settings.json for you):",
226
+ " cc-limits --install configure the status line",
227
+ " cc-limits --install --segments=session,week ...with display options",
228
+ " cc-limits --uninstall remove it again",
229
+ "",
230
+ "Or set it manually in ~/.claude/settings.json:",
231
+ ' "statusLine": { "type": "command", "command": "cc-limits" }',
232
+ "",
233
+ "Segments (default: all, in this order): model, context, session, week",
234
+ " --segments=session,week Show only these, in this order",
235
+ " --no-context Hide a single segment (repeatable)",
236
+ " --no-model --no-week ...",
237
+ "",
238
+ "Reset countdowns:",
239
+ " --reset=both|session|week|none Which resets to show (default both)",
240
+ " --no-reset Same as --reset=none",
241
+ "",
242
+ "Other flags: --demo, --no-color, -h/--help",
243
+ "",
244
+ "Env vars:",
245
+ " CC_LIMITS_SEGMENTS=model,context,session,week",
246
+ " CC_LIMITS_RESET=both|session|week|none",
247
+ " CC_LIMITS_WARN=70 yellow threshold (% of a limit)",
248
+ " CC_LIMITS_CRIT=90 red threshold",
249
+ " CC_LIMITS_SEP=' | ' segment separator",
250
+ " NO_COLOR disable colors",
251
+ ].join("\n")
252
+ );
253
+ process.exit(0);
254
+ }
255
+
256
+ // ---------- install / uninstall into ~/.claude/settings.json ----------
257
+ function settingsPath() {
258
+ return path.join(os.homedir(), ".claude", "settings.json");
259
+ }
260
+ function quoteArg(s) {
261
+ return /[\s"\\]/.test(s) ? '"' + s.replace(/(["\\])/g, "\\$1") + '"' : s;
262
+ }
263
+ function buildCommand(passthrough) {
264
+ // Absolute node + script path => immune to the non-login-shell PATH issue
265
+ // (e.g. nvm) that can leave a globally-installed `cc-limits` off the PATH.
266
+ return [process.execPath, __filename, ...passthrough].map(quoteArg).join(" ");
267
+ }
268
+ function readSettings(p) {
269
+ let raw;
270
+ try {
271
+ raw = fs.readFileSync(p, "utf8");
272
+ } catch (e) {
273
+ if (e.code === "ENOENT") return { settings: {}, existed: false };
274
+ console.error("cc-limits: cannot read " + p + ": " + e.message);
275
+ process.exit(1);
276
+ }
277
+ try {
278
+ return { settings: raw.trim() ? JSON.parse(raw) : {}, existed: true };
279
+ } catch {
280
+ console.error(
281
+ "cc-limits: " + p + " is not valid JSON — aborting so it isn't clobbered.\n" +
282
+ "Fix or remove it, then re-run, or configure the status line manually."
283
+ );
284
+ process.exit(1);
285
+ }
286
+ }
287
+ function doInstall(passthrough) {
288
+ const p = settingsPath();
289
+ const { settings, existed } = readSettings(p);
290
+ settings.statusLine = { type: "command", command: buildCommand(passthrough) };
291
+ fs.mkdirSync(path.dirname(p), { recursive: true });
292
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
293
+ console.log("cc-limits: status line " + (existed ? "updated in " : "written to ") + p);
294
+ console.log("→ " + settings.statusLine.command);
295
+ console.log("Open a NEW Claude Code session and send one message to see it.");
296
+ }
297
+ function doUninstall() {
298
+ const p = settingsPath();
299
+ const { settings, existed } = readSettings(p);
300
+ if (!existed || !settings.statusLine) {
301
+ console.log("cc-limits: no status line configured in " + p + " — nothing to do.");
302
+ return;
303
+ }
304
+ delete settings.statusLine;
305
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n");
306
+ console.log("cc-limits: removed status line from " + p);
307
+ }
308
+
309
+ if (argv.includes("--install")) {
310
+ const passthrough = argv.filter((a) => a !== "--install" && a !== "--uninstall");
311
+ doInstall(passthrough);
312
+ process.exit(0);
313
+ }
314
+ if (argv.includes("--uninstall")) {
315
+ doUninstall();
316
+ process.exit(0);
317
+ }
318
+
319
+ if (argv.includes("--demo")) {
320
+ out(render(demoPayload()));
321
+ process.exit(0);
322
+ }
323
+
324
+ if (process.stdin.isTTY) {
325
+ out(
326
+ render(demoPayload()) +
327
+ " " +
328
+ paint("(demo — pipe Claude Code JSON in)", C.dim)
329
+ );
330
+ process.exit(0);
331
+ }
332
+
333
+ const MAX_INPUT = 1 << 20; // 1 MB safety cap on stdin
334
+ let input = "";
335
+ let done = false;
336
+
337
+ function finish(raw) {
338
+ if (done) return;
339
+ done = true;
340
+ let data = {};
341
+ try {
342
+ data = raw ? JSON.parse(raw) : {};
343
+ } catch {
344
+ data = {};
345
+ }
346
+ out(render(data));
347
+ }
348
+
349
+ // Never hang: if no EOF arrives, render with whatever we have after 2s.
350
+ const timer = setTimeout(() => finish(input), 2000);
351
+ if (timer.unref) timer.unref();
352
+
353
+ process.stdin.setEncoding("utf8");
354
+ process.stdin.on("data", (chunk) => {
355
+ if (input.length < MAX_INPUT) input += chunk;
356
+ });
357
+ process.stdin.on("error", () => {
358
+ clearTimeout(timer);
359
+ finish("");
360
+ });
361
+ process.stdin.on("end", () => {
362
+ clearTimeout(timer);
363
+ finish(input);
364
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "claude-limit-statusline",
3
+ "version": "0.2.0",
4
+ "description": "Claude Code status line that shows your REAL Pro/Max subscription limits — 5-hour session and 7-day weekly usage with a live reset countdown. Uses the official rate_limits payload, not a local estimate.",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "cc-limits": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "demo": "node bin/cli.js --demo"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "claude",
22
+ "claude-code",
23
+ "statusline",
24
+ "status-line",
25
+ "rate-limits",
26
+ "usage",
27
+ "pro",
28
+ "max",
29
+ "subscription",
30
+ "anthropic"
31
+ ],
32
+ "author": "Ann0nip (https://github.com/ann0nip)",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/ann0nip/claude-limit-statusline.git"
37
+ },
38
+ "homepage": "https://github.com/ann0nip/claude-limit-statusline#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/ann0nip/claude-limit-statusline/issues"
41
+ }
42
+ }