claude-usage-guard 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) 2026 Esteban Cerutti
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,134 @@
1
+ # claude-usage-guard
2
+
3
+ [![CI](https://github.com/ecerutti/claude-usage-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/ecerutti/claude-usage-guard/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
6
+
7
+ An MCP server that exposes Claude Code's real-time rate limit usage so that an orchestrator (Leader agent) can check remaining capacity before launching subtasks — and pause when approaching limits rather than failing mid-workflow.
8
+
9
+ ## The problem
10
+
11
+ Claude Code enforces two rate limit windows: a 5-hour session window and a 7-day weekly window. When running multi-agent workflows, an orchestrator has no native way to know how much quota remains before launching a batch of subtasks. The workflow can stall mid-execution when the limit is hit, leaving work in an incomplete state.
12
+
13
+ ## How it works
14
+
15
+ Two components work together:
16
+
17
+ **1. statusLine capture script** (`scripts/usage-capture.cjs`)
18
+ Registered as Claude Code's `statusLine` command. Receives the internal JSON that Claude Code passes after each response — which includes the parsed `anthropic-ratelimit-*` headers — and persists the rate limit data to `~/.claude/usage_state.json`.
19
+
20
+ **2. MCP server** (`index.js`)
21
+ Exposes a single tool `check_usage_limits` that reads `~/.claude/usage_state.json` and returns structured data the orchestrator can reason over.
22
+
23
+ > **Note:** The `rate_limits` field is only available for Claude.ai Pro/Max subscribers (not direct API key users), and only after the first API response in a session.
24
+
25
+ ## Installation
26
+
27
+ **Requirements:** Node.js 18+, Claude Code CLI
28
+
29
+ ### Option A — npm (recommended)
30
+
31
+ ```bash
32
+ npm install -g claude-usage-guard
33
+ ```
34
+
35
+ Register the MCP server at user scope:
36
+
37
+ ```bash
38
+ claude mcp add --transport stdio --scope user usage-guard -- claude-usage-guard
39
+ ```
40
+
41
+ Then add the `statusLine` entry to `~/.claude/settings.json`:
42
+
43
+ ```json
44
+ "statusLine": {
45
+ "type": "command",
46
+ "command": "claude-usage-guard-statusline"
47
+ }
48
+ ```
49
+
50
+ > If Claude Code can't find the command (e.g. you use nvm and its bin dir isn't
51
+ > on Claude Code's PATH), use the absolute path printed by `which claude-usage-guard`
52
+ > / `which claude-usage-guard-statusline` instead.
53
+
54
+ ### Option B — git clone + setup script
55
+
56
+ ```bash
57
+ git clone https://github.com/ecerutti/claude-usage-guard.git
58
+ cd claude-usage-guard
59
+ bash setup.sh
60
+ ```
61
+
62
+ The setup script:
63
+ - Installs npm dependencies
64
+ - Copies the capture script to `~/.claude/scripts/`
65
+ - Registers the MCP server at user scope (`claude mcp add --scope user`)
66
+
67
+ Then add the `statusLine` entry to `~/.claude/settings.json` manually (the setup script prints the exact snippet to add). To remove everything later, run `bash uninstall.sh`.
68
+
69
+ ### Verify
70
+
71
+ ```bash
72
+ claude mcp list
73
+ # → usage-guard: ... - ✔ Connected
74
+ ```
75
+
76
+ ## Tool output
77
+
78
+ `check_usage_limits` takes no parameters and returns:
79
+
80
+ ```json
81
+ {
82
+ "session_used_pct": 42.5,
83
+ "session_resets_at": "2026-06-25T18:30:00.000Z",
84
+ "session_resets_in_seconds": 5700,
85
+ "weekly_used_pct": 15.3,
86
+ "weekly_resets_at": "2026-07-02T04:00:00.000Z",
87
+ "weekly_resets_in_seconds": 601500,
88
+ "data_freshness": "fresh",
89
+ "captured_at": "2026-06-25T16:45:00.000Z"
90
+ }
91
+ ```
92
+
93
+ | Field | Description |
94
+ |---|---|
95
+ | `session_used_pct` | % of 5-hour window consumed (0–100) |
96
+ | `weekly_used_pct` | % of 7-day window consumed (0–100) |
97
+ | `*_resets_in_seconds` | Seconds until that window resets |
98
+ | `data_freshness` | `"fresh"` <60s · `"stale"` 60–300s · `"unavailable"` >300s or no data |
99
+ | `captured_at` | ISO 8601 timestamp of last capture |
100
+
101
+ Each window field can be `null` independently if not yet available.
102
+
103
+ ## Using in an orchestrator prompt
104
+
105
+ Add this to your Leader agent's CLAUDE.md or system prompt:
106
+
107
+ ```
108
+ Before launching any subtask (Task tool or parallel agents), call check_usage_limits.
109
+
110
+ Decision rules:
111
+ - data_freshness "unavailable" → proceed with caution, no usage data yet
112
+ - session_used_pct > 80 → do NOT launch heavy tasks; check session_resets_in_seconds,
113
+ wait that duration (Bash sleep or schedule a wakeup), then re-check before continuing
114
+ - Otherwise → proceed normally
115
+
116
+ Always note remaining capacity in your task plan so you can resume pending work
117
+ after a reset if you need to pause mid-workflow.
118
+ ```
119
+
120
+ ## Design decisions
121
+
122
+ - **No recommendation field.** The tool exposes raw data only. The orchestrator model reasons over it directly — pre-baked thresholds would be wrong for different workloads.
123
+ - **Fail-open.** If data is unavailable, the tool returns null fields rather than blocking — the orchestrator decides what to do with missing information.
124
+ - **statusLine is the only reliable source.** Scraping claude.ai is blocked by Cloudflare. The `rate_limits` field in the statusLine JSON is the only official, non-fragile way to access this data from outside the API layer.
125
+
126
+ ## Contributing
127
+
128
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to set
129
+ up the project, run the tests (`npm test`), and open a pull request. For security
130
+ issues, see [SECURITY.md](SECURITY.md).
131
+
132
+ ## License
133
+
134
+ [MIT](LICENSE) © Esteban Cerutti
package/index.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { readFileSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ import { buildUsageReport } from './lib/usage.js';
9
+
10
+ const STATE_FILE = join(homedir(), '.claude', 'usage_state.json');
11
+
12
+ function checkUsageLimits() {
13
+ let raw = null;
14
+ try {
15
+ raw = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
16
+ } catch {
17
+ // Missing or unreadable state file → buildUsageReport returns the
18
+ // "unavailable" shape (all fields null). Fail-open by design.
19
+ }
20
+ return buildUsageReport(raw);
21
+ }
22
+
23
+ const server = new Server(
24
+ { name: 'usage-guard', version: '1.0.0' },
25
+ { capabilities: { tools: {} } }
26
+ );
27
+
28
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
29
+ tools: [{
30
+ name: 'check_usage_limits',
31
+ description: 'Returns current Claude Code rate limit usage. session_used_pct and weekly_used_pct are 0-100. data_freshness is "fresh" (<60s), "stale" (60-300s), or "unavailable" (>300s or no data).',
32
+ inputSchema: { type: 'object', properties: {}, required: [] },
33
+ }],
34
+ }));
35
+
36
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
37
+ if (request.params.name !== 'check_usage_limits') {
38
+ throw new Error(`Unknown tool: ${request.params.name}`);
39
+ }
40
+ return {
41
+ content: [{
42
+ type: 'text',
43
+ text: JSON.stringify(checkUsageLimits(), null, 2),
44
+ }],
45
+ };
46
+ });
47
+
48
+ const transport = new StdioServerTransport();
49
+ await server.connect(transport);
package/lib/usage.js ADDED
@@ -0,0 +1,55 @@
1
+ // Pure, side-effect-free helpers for turning a captured usage_state.json
2
+ // snapshot into the structured report exposed by the MCP tool. Kept separate
3
+ // from index.js so the transformation logic can be unit-tested without I/O.
4
+
5
+ export const FRESH_THRESHOLD = 60; // seconds
6
+ export const STALE_THRESHOLD = 300; // seconds
7
+
8
+ export function toISOLocal(epochSeconds) {
9
+ return new Date(epochSeconds * 1000).toISOString();
10
+ }
11
+
12
+ export function secondsUntil(epochSeconds, now = Date.now()) {
13
+ return Math.max(0, Math.floor(epochSeconds - now / 1000));
14
+ }
15
+
16
+ export function getFreshness(capturedAt, now = Date.now()) {
17
+ const ageSeconds = (now - new Date(capturedAt).getTime()) / 1000;
18
+ if (ageSeconds < FRESH_THRESHOLD) return 'fresh';
19
+ if (ageSeconds < STALE_THRESHOLD) return 'stale';
20
+ return 'unavailable';
21
+ }
22
+
23
+ function unavailableReport() {
24
+ return {
25
+ session_used_pct: null,
26
+ session_resets_at: null,
27
+ session_resets_in_seconds: null,
28
+ weekly_used_pct: null,
29
+ weekly_resets_at: null,
30
+ weekly_resets_in_seconds: null,
31
+ data_freshness: 'unavailable',
32
+ captured_at: null,
33
+ };
34
+ }
35
+
36
+ // raw: parsed contents of usage_state.json, or null/undefined when the file
37
+ // is missing or unreadable. Returns the report shape the MCP tool emits.
38
+ export function buildUsageReport(raw, now = Date.now()) {
39
+ if (!raw) return unavailableReport();
40
+
41
+ const freshness = getFreshness(raw.captured_at, now);
42
+ const fh = raw.five_hour;
43
+ const sd = raw.seven_day;
44
+
45
+ return {
46
+ session_used_pct: fh?.used_percentage ?? null,
47
+ session_resets_at: fh?.resets_at ? toISOLocal(fh.resets_at) : null,
48
+ session_resets_in_seconds: fh?.resets_at ? secondsUntil(fh.resets_at, now) : null,
49
+ weekly_used_pct: sd?.used_percentage ?? null,
50
+ weekly_resets_at: sd?.resets_at ? toISOLocal(sd.resets_at) : null,
51
+ weekly_resets_in_seconds: sd?.resets_at ? secondsUntil(sd.resets_at, now) : null,
52
+ data_freshness: freshness,
53
+ captured_at: raw.captured_at ?? null,
54
+ };
55
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "claude-usage-guard",
3
+ "version": "1.0.0",
4
+ "description": "MCP server that exposes Claude Code's real-time rate limit usage so orchestrators can check remaining capacity before launching subtasks.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "claude-usage-guard": "index.js",
9
+ "claude-usage-guard-statusline": "scripts/usage-capture.cjs"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "lib/",
14
+ "scripts/usage-capture.cjs",
15
+ "setup.sh",
16
+ "uninstall.sh",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test",
25
+ "start": "node index.js"
26
+ },
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "claude",
31
+ "claude-code",
32
+ "rate-limit",
33
+ "usage",
34
+ "orchestrator",
35
+ "agents"
36
+ ],
37
+ "author": "Esteban Cerutti <esteban.cerutti@gmail.com>",
38
+ "license": "MIT",
39
+ "homepage": "https://github.com/ecerutti/claude-usage-guard#readme",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ecerutti/claude-usage-guard.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/ecerutti/claude-usage-guard/issues"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.12.0"
49
+ }
50
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ const { writeFileSync } = require('fs');
3
+ const { homedir } = require('os');
4
+ const { join } = require('path');
5
+
6
+ const chunks = [];
7
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
8
+ process.stdin.on('end', () => {
9
+ let input = {};
10
+ try {
11
+ input = JSON.parse(Buffer.concat(chunks).toString());
12
+ } catch {}
13
+
14
+ const rateLimits = input.rate_limits;
15
+
16
+ if (rateLimits) {
17
+ const state = {
18
+ captured_at: new Date().toISOString(),
19
+ five_hour: rateLimits.five_hour ?? null,
20
+ seven_day: rateLimits.seven_day ?? null,
21
+ };
22
+ try {
23
+ writeFileSync(join(homedir(), '.claude', 'usage_state.json'), JSON.stringify(state, null, 2));
24
+ } catch {}
25
+ }
26
+
27
+ const fh = rateLimits?.five_hour;
28
+ const sd = rateLimits?.seven_day;
29
+
30
+ const parts = [];
31
+ if (fh != null) parts.push(`5h: ${Math.round(fh.used_percentage)}%`);
32
+ if (sd != null) parts.push(`7d: ${Math.round(sd.used_percentage)}%`);
33
+
34
+ process.stdout.write((parts.length ? `usage: ${parts.join(' | ')}` : '') + '\n');
35
+ });
package/setup.sh ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+
6
+ if [ "${1:-}" = "--reinstall" ]; then
7
+ echo "Reinstalling: running uninstall first..."
8
+ bash "$SCRIPT_DIR/uninstall.sh" || true
9
+ echo ""
10
+ fi
11
+
12
+ # Detect node with absolute path (handles nvm, asdf, and non-standard installs)
13
+ NODE_BIN=$(command -v node 2>/dev/null || command -v nodejs 2>/dev/null || true)
14
+ if [ -z "$NODE_BIN" ]; then
15
+ echo "Error: Node.js not found in PATH."
16
+ echo "Install it from https://nodejs.org or via nvm: https://github.com/nvm-sh/nvm"
17
+ exit 1
18
+ fi
19
+ NODE_BIN=$(realpath "$NODE_BIN")
20
+ echo "Using Node.js: $NODE_BIN ($(${NODE_BIN} --version))"
21
+
22
+ echo "Installing dependencies..."
23
+ npm install --prefix "$SCRIPT_DIR"
24
+
25
+ echo "Copying statusLine capture script..."
26
+ mkdir -p ~/.claude/scripts
27
+ cp "$SCRIPT_DIR/scripts/usage-capture.cjs" ~/.claude/scripts/usage-capture.cjs
28
+
29
+ echo "Registering MCP server (user scope)..."
30
+ claude mcp add --transport stdio --scope user usage-guard -- "$NODE_BIN" "$SCRIPT_DIR/index.js"
31
+
32
+ echo ""
33
+ echo "Done. One manual step remaining — add this to ~/.claude/settings.json:"
34
+ echo ""
35
+ echo ' "statusLine": {'
36
+ echo ' "type": "command",'
37
+ echo " \"command\": \"$NODE_BIN $HOME/.claude/scripts/usage-capture.cjs\""
38
+ echo ' }'
39
+ echo ""
40
+ echo "Then restart Claude Code. The tool check_usage_limits will be available."
package/uninstall.sh ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ echo "Uninstalling claude-usage-guard..."
5
+
6
+ # 1. Remove MCP server registration
7
+ if claude mcp remove usage-guard 2>/dev/null; then
8
+ echo "✓ MCP server removed"
9
+ else
10
+ echo " MCP server was not registered (skipping)"
11
+ fi
12
+
13
+ # 2. Remove statusLine from ~/.claude/settings.json
14
+ SETTINGS="$HOME/.claude/settings.json"
15
+ if [ -f "$SETTINGS" ]; then
16
+ NODE_BIN=$(command -v node 2>/dev/null || command -v nodejs 2>/dev/null || true)
17
+ if [ -n "$NODE_BIN" ]; then
18
+ NODE_BIN=$(realpath "$NODE_BIN")
19
+ fi
20
+ PYTHON_BIN=$(command -v python3 2>/dev/null || true)
21
+
22
+ if [ -n "$NODE_BIN" ]; then
23
+ "$NODE_BIN" -e "
24
+ const fs = require('fs');
25
+ const s = JSON.parse(fs.readFileSync(process.env.SETTINGS, 'utf-8'));
26
+ delete s.statusLine;
27
+ fs.writeFileSync(process.env.SETTINGS, JSON.stringify(s, null, 2) + '\n');
28
+ " SETTINGS="$SETTINGS" && echo "✓ statusLine removed from settings.json"
29
+ elif [ -n "$PYTHON_BIN" ]; then
30
+ SETTINGS="$SETTINGS" "$PYTHON_BIN" -c "
31
+ import json, os
32
+ path = os.environ['SETTINGS']
33
+ with open(path) as f:
34
+ s = json.load(f)
35
+ s.pop('statusLine', None)
36
+ with open(path, 'w') as f:
37
+ json.dump(s, f, indent=2)
38
+ f.write('\n')
39
+ " && echo "✓ statusLine removed from settings.json"
40
+ else
41
+ echo "! Could not auto-edit settings.json (no node or python3 found)"
42
+ echo " Remove the \"statusLine\" key manually from: $SETTINGS"
43
+ fi
44
+ else
45
+ echo " settings.json not found (skipping)"
46
+ fi
47
+
48
+ # 3. Remove capture script (and any legacy .js copy from older versions)
49
+ rm -f "$HOME/.claude/scripts/usage-capture.cjs" "$HOME/.claude/scripts/usage-capture.js"
50
+ echo "✓ Capture script removed"
51
+
52
+ # 4. Remove state file
53
+ rm -f "$HOME/.claude/usage_state.json"
54
+ echo "✓ State file removed"
55
+
56
+ echo ""
57
+ echo "Done. Restart Claude Code to apply changes."
58
+ echo "You can now delete this repository directory if you no longer need it."