contextbricks-universal 4.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jeremy Dawes (Jezweb)
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,218 @@
1
+ # ContextBricks Universal
2
+
3
+ > Cross-platform statusline for [Claude Code](https://claude.ai/code) CLI with real-time context brick visualization.
4
+
5
+ **Works on Windows, Linux, and macOS** — pure Node.js, no bash or jq required.
6
+
7
+ ```
8
+ [Sonnet 4.5] claude-skills:main *↑2 | +145/-23
9
+ [5f2ce67] Remove auth-js skill
10
+ [■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□] 43% | 113k free | 0h12m | $0.87
11
+ 5h:64% ~23m | 7d:57% ~1d23h | sonnet:9% ~3d23h
12
+ ```
13
+
14
+ ## Features
15
+
16
+ - **Real-time context tracking** — brick visualization of context window usage
17
+ - **Rate limit tracking** — 5-hour and 7-day utilization with reset timers (Max/Pro subscribers)
18
+ - **Official percentage fields** (Claude Code 2.1.6+) with fallback calculation (2.0.70+)
19
+ - **Git integration** — repo, branch, commit hash, message, dirty/ahead/behind indicators
20
+ - **Session metrics** — model name, lines changed, duration, cost (hidden for Max subscribers)
21
+ - **Environment config** — `CONTEXTBRICKS_SHOW_DIR`, `CONTEXTBRICKS_BRICKS`, `CONTEXTBRICKS_SHOW_LIMITS`
22
+
23
+ ## Installation
24
+
25
+ ### Quick Install (Recommended)
26
+
27
+ ```bash
28
+ npm install -g contextbricks-universal
29
+ contextbricks install
30
+ ```
31
+
32
+ Or one-liner via npx (installs and runs automatically):
33
+
34
+ ```bash
35
+ npx contextbricks-universal
36
+ ```
37
+
38
+ Node.js is the only requirement (already present if you use Claude Code).
39
+
40
+ ### From GitHub (without npm)
41
+
42
+ ```bash
43
+ npm install -g github:thebtf/contextbricks-universal
44
+ contextbricks install
45
+ ```
46
+
47
+ ### From Source
48
+
49
+ ```bash
50
+ git clone https://github.com/thebtf/contextbricks-universal.git
51
+ cd contextbricks-universal
52
+ node bin/cli.js install
53
+ ```
54
+
55
+ ### What the Installer Does
56
+
57
+ 1. Copies `statusline.js` to `~/.claude/statusline.js`
58
+ 2. Updates `~/.claude/settings.json` with the `statusLine` command
59
+ 3. Backs up existing configuration before any changes
60
+
61
+ ## Display Layout
62
+
63
+ ### Line 1 — Model + Git + Changes
64
+
65
+ ```
66
+ [Sonnet 4.5] claude-skills:main *↑2 | +145/-23
67
+ ```
68
+
69
+ ### Line 2 — Commit Details
70
+
71
+ ```
72
+ [5f2ce67] Remove auth-js skill
73
+ ```
74
+
75
+ ### Line 3 — Context Bricks
76
+
77
+ ```
78
+ [■■■■■■■■■■■■■□□□□□□□□□□□□□□□□□] 43% | 113k free | 0h12m | $0.87
79
+ ```
80
+
81
+ ### Line 4 — Rate Limits (Max/Pro subscribers)
82
+
83
+ ```
84
+ 5h:64% ~23m | 7d:57% ~1d23h | sonnet:9% ~3d23h
85
+ ```
86
+
87
+ | Symbol | Meaning |
88
+ |--------|---------|
89
+ | `■` (cyan) | Used context |
90
+ | `□` (dim) | Free space |
91
+ | `*` | Uncommitted changes |
92
+ | `↑3` | Ahead of remote by 3 |
93
+ | `↓2` | Behind remote by 2 |
94
+ | `5h:X%` | 5-hour rolling limit utilization |
95
+ | `7d:X%` | 7-day overall limit utilization |
96
+ | `sonnet:X%` | 7-day Sonnet sub-limit (if applicable) |
97
+ | `opus:X%` | 7-day Opus sub-limit (if applicable) |
98
+ | `~22m` / `~1d23h` | Time until limit resets (exact by default) |
99
+
100
+ ## Configuration
101
+
102
+ | Environment Variable | Default | Description |
103
+ |---|---|---|
104
+ | `CONTEXTBRICKS_SHOW_DIR` | `1` | Show current subdirectory (`0` to hide) |
105
+ | `CONTEXTBRICKS_BRICKS` | `30` | Number of bricks in the visualization |
106
+ | `CONTEXTBRICKS_SHOW_LIMITS` | `1` | Show rate limit utilization (`0` to hide) |
107
+ | `CONTEXTBRICKS_RESET_EXACT` | `1` | Exact reset times `~1d23h` (`0` for approximate `~1d`) |
108
+
109
+ ## How It Works
110
+
111
+ ### Context Tracking (Claude Code 2.1.6+)
112
+
113
+ Uses pre-calculated percentage fields:
114
+
115
+ ```json
116
+ {
117
+ "context_window": {
118
+ "context_window_size": 200000,
119
+ "used_percentage": 43.5,
120
+ "remaining_percentage": 56.5
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### Fallback (Claude Code 2.0.70+)
126
+
127
+ Calculates from `current_usage` token counts when percentage fields are unavailable.
128
+
129
+ ### Settings
130
+
131
+ The installer configures `~/.claude/settings.json`:
132
+
133
+ ```json
134
+ {
135
+ "statusLine": {
136
+ "type": "command",
137
+ "command": "node ~/.claude/statusline.js",
138
+ "padding": 0
139
+ }
140
+ }
141
+ ```
142
+
143
+ ## Rate Limit Tracking
144
+
145
+ Line 4 shows your current utilization of Claude's rate limits — useful for Max and Pro subscribers to avoid hitting caps.
146
+
147
+ ### How It Works
148
+
149
+ 1. Reads your OAuth token from Claude Code credentials (`~/.claude/.credentials.json`, or macOS keychain)
150
+ 2. Fetches usage data from `api.anthropic.com/api/oauth/usage`
151
+ 3. Caches the response for 5 minutes (`~/.claude/.usage-cache.json`)
152
+ 4. Displays utilization percentages with color coding:
153
+ - **Green** (0-49%) — plenty of capacity
154
+ - **Yellow** (50-79%) — approaching limit
155
+ - **Red** (80-100%) — near or at limit
156
+
157
+ ### Requirements
158
+
159
+ - Active Claude Max or Pro subscription with OAuth credentials
160
+ - API-only users will not see Line 4 (gracefully skipped)
161
+
162
+ ### Privacy
163
+
164
+ - Your OAuth token is never logged or exposed in command arguments
165
+ - Token is passed to the HTTPS subprocess via environment variable
166
+ - Cache file (`~/.claude/.usage-cache.json`) is stored locally
167
+
168
+ ### Troubleshooting
169
+
170
+ | Issue | Solution |
171
+ |-------|----------|
172
+ | Line 4 not showing | Verify `~/.claude/.credentials.json` exists with `claudeAiOauth.accessToken` |
173
+ | Stale data | Delete `~/.claude/.usage-cache.json` to force refresh |
174
+ | Want to hide Line 4 | Set `CONTEXTBRICKS_SHOW_LIMITS=0` |
175
+
176
+ ## Testing
177
+
178
+ ```bash
179
+ contextbricks test
180
+ ```
181
+
182
+ ## Requirements
183
+
184
+ - **Node.js** >= 14
185
+ - **git** (optional, for git info display)
186
+
187
+ No bash, jq, bc, sed, cut, or any other Unix tools required.
188
+
189
+ ## Bash vs Node.js
190
+
191
+ | | Bash (v3.5) | Node.js (v4.x) |
192
+ |---|---|---|
193
+ | **Platform** | Linux/macOS only | Windows + Linux + macOS |
194
+ | **Dependencies** | bash, jq, git, bc, sed, cut | Node.js only (git optional) |
195
+ | **JSON parsing** | External `jq` | Native `JSON.parse()` |
196
+ | **Install** | Shell scripts | `npm i -g contextbricks-universal` |
197
+ | **Output** | Identical | Identical |
198
+
199
+ ## Uninstallation
200
+
201
+ ```bash
202
+ contextbricks uninstall
203
+ ```
204
+
205
+ Or manually:
206
+ 1. Delete `~/.claude/statusline.js`
207
+ 2. Remove the `statusLine` section from `~/.claude/settings.json`
208
+ 3. Restart Claude Code
209
+
210
+ ## Acknowledgements
211
+
212
+ This project is a cross-platform Node.js rewrite of [ContextBricks](https://github.com/jezweb/claude-skills/tree/main/tools/statusline) created by [Jeremy Dawes](https://github.com/jezweb) ([jezweb.com.au](https://jezweb.com.au)). The original bash implementation provided the foundation for the statusline format, brick visualization, and git integration.
213
+
214
+ Also inspired by [ccstatusline](https://github.com/sirmalloc/ccstatusline).
215
+
216
+ ## License
217
+
218
+ [MIT](LICENSE) — Copyright (c) 2025 Jeremy Dawes (Jezweb)
package/bin/cli.js ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const { spawnSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const command = process.argv[2];
11
+ const STATUSLINE_SCRIPT = path.join(__dirname, '..', 'scripts', 'statusline.js');
12
+
13
+ // Colors for terminal output
14
+ const c = {
15
+ reset: '\x1b[0m',
16
+ red: '\x1b[31m',
17
+ green: '\x1b[32m',
18
+ yellow: '\x1b[33m',
19
+ cyan: '\x1b[36m',
20
+ bold: '\x1b[1m',
21
+ dim: '\x1b[2m',
22
+ };
23
+
24
+ // Paths
25
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
26
+ const INSTALL_PATH = path.join(CLAUDE_DIR, 'statusline.js');
27
+ const SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
28
+
29
+ function checkDependencies() {
30
+ // git is optional but recommended
31
+ const gitResult = spawnSync('git', ['--version'], { stdio: 'pipe', windowsHide: true, timeout: 5000 });
32
+ if (gitResult.status !== 0) {
33
+ console.warn(`${c.yellow}Warning: git not found. Git info will not be available.${c.reset}`);
34
+ }
35
+
36
+ console.log(`${c.green}Dependencies OK${c.reset} (Node.js ${process.version})`);
37
+ }
38
+
39
+ function backupFile(filePath) {
40
+ if (fs.existsSync(filePath)) {
41
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
42
+ const backupPath = `${filePath}.backup-${timestamp}`;
43
+ fs.copyFileSync(filePath, backupPath);
44
+ return backupPath;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function install() {
50
+ console.log(`\n${c.cyan}${c.bold}ContextBricks${c.reset} - Claude Code Status Line Installer\n`);
51
+
52
+ console.log('Checking dependencies...');
53
+ checkDependencies();
54
+ console.log('');
55
+
56
+ // Ensure ~/.claude exists
57
+ if (!fs.existsSync(CLAUDE_DIR)) {
58
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
59
+ console.log(`Created: ${CLAUDE_DIR}`);
60
+ }
61
+
62
+ // Backup existing statusline script
63
+ const scriptBackup = backupFile(INSTALL_PATH);
64
+ if (scriptBackup) {
65
+ console.log(`Backed up existing script: ${scriptBackup}`);
66
+ }
67
+
68
+ // Copy statusline.js
69
+ console.log('Installing status line script...');
70
+ if (!fs.existsSync(STATUSLINE_SCRIPT)) {
71
+ console.error(`${c.red}Error: Source script not found: ${STATUSLINE_SCRIPT}${c.reset}`);
72
+ process.exit(1);
73
+ }
74
+ fs.copyFileSync(STATUSLINE_SCRIPT, INSTALL_PATH);
75
+ console.log(` Installed: ${INSTALL_PATH}`);
76
+ console.log('');
77
+
78
+ // Build the command string for settings.json
79
+ // Use forward slashes for cross-platform compatibility
80
+ const homedir = os.homedir().replace(/\\/g, '/');
81
+ const statuslineCommand = `node ${homedir}/.claude/statusline.js`;
82
+
83
+ // Update settings.json
84
+ const settingsBackup = backupFile(SETTINGS_FILE);
85
+ if (settingsBackup) {
86
+ console.log(`Backed up settings: ${settingsBackup}`);
87
+ }
88
+
89
+ let settings = {};
90
+ if (fs.existsSync(SETTINGS_FILE)) {
91
+ try {
92
+ settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
93
+ } catch {
94
+ console.warn(`${c.yellow}Warning: Could not parse settings.json, creating new one${c.reset}`);
95
+ }
96
+ }
97
+
98
+ settings.statusLine = {
99
+ type: 'command',
100
+ command: statuslineCommand,
101
+ padding: 0,
102
+ };
103
+
104
+ try {
105
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n', 'utf8');
106
+ console.log(' Settings updated');
107
+ } catch (err) {
108
+ console.error(`${c.red}Error: Could not write settings.json: ${err.message}${c.reset}`);
109
+ process.exit(1);
110
+ }
111
+
112
+ console.log(`
113
+ ${c.green}Installation complete!${c.reset}
114
+
115
+ ${c.bold}Your status line will show:${c.reset}
116
+ - Model name (Sonnet 4.5, Opus 4, etc.)
117
+ - Git repo:branch [commit] message
118
+ - Git status indicators (*uncommitted, ↑ahead, ↓behind)
119
+ - Lines changed this session (+added/-removed)
120
+ - Real-time context usage with brick visualization
121
+ - Session duration and cost
122
+
123
+ ${c.cyan}Restart Claude Code to see your new status line!${c.reset}
124
+
125
+ To uninstall: contextbricks uninstall
126
+ `);
127
+ }
128
+
129
+ function uninstall() {
130
+ console.log(`\n${c.cyan}${c.bold}ContextBricks${c.reset} - Uninstaller\n`);
131
+
132
+ // Remove statusline script
133
+ if (fs.existsSync(INSTALL_PATH)) {
134
+ console.log('Removing status line script...');
135
+ fs.unlinkSync(INSTALL_PATH);
136
+ console.log(` Removed: ${INSTALL_PATH}`);
137
+ } else {
138
+ console.log(`${c.yellow}Status line script not found (already removed?)${c.reset}`);
139
+ }
140
+
141
+ console.log('');
142
+
143
+ // Update settings.json - remove statusLine key
144
+ if (fs.existsSync(SETTINGS_FILE)) {
145
+ try {
146
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
147
+ if (settings.statusLine) {
148
+ delete settings.statusLine;
149
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n', 'utf8');
150
+ console.log('Removed statusLine from settings.json');
151
+ }
152
+ } catch {
153
+ console.warn(`${c.yellow}Warning: Could not update settings.json${c.reset}`);
154
+ }
155
+ }
156
+
157
+ // List backups for manual cleanup
158
+ const backups = [];
159
+ try {
160
+ const files = fs.readdirSync(CLAUDE_DIR);
161
+ for (const file of files) {
162
+ if (file.startsWith('statusline.js.backup-') || file.startsWith('settings.json.backup-')) {
163
+ backups.push(path.join(CLAUDE_DIR, file));
164
+ }
165
+ }
166
+ } catch {
167
+ // ignore
168
+ }
169
+
170
+ if (backups.length > 0) {
171
+ console.log(`\nFound ${backups.length} backup file(s).`);
172
+ console.log('Keeping backups (delete manually if needed):');
173
+ for (const b of backups) {
174
+ console.log(` ${b}`);
175
+ }
176
+ }
177
+
178
+ console.log(`\n${c.green}Uninstallation complete!${c.reset}`);
179
+ console.log(`${c.cyan}Restart Claude Code for changes to take effect.${c.reset}\n`);
180
+ }
181
+
182
+ function showHelp() {
183
+ console.log(`
184
+ ${c.cyan}${c.bold}ContextBricks${c.reset} - Claude Code Status Line (Cross-Platform)
185
+
186
+ ${c.green}Usage:${c.reset}
187
+ contextbricks Install status line (default)
188
+ contextbricks install Install status line
189
+ contextbricks uninstall Uninstall status line
190
+ contextbricks test Test with sample data
191
+ contextbricks --help Show this help
192
+ contextbricks --version Show version
193
+
194
+ ${c.green}Features:${c.reset}
195
+ - Real-time context tracking with brick visualization
196
+ - Git integration (repo, branch, commit, status)
197
+ - Session metrics (duration, cost, lines changed)
198
+ - Works on ${c.bold}Windows${c.reset}, Linux, and macOS (no bash/jq required)
199
+
200
+ ${c.green}More Info:${c.reset}
201
+ GitHub: https://github.com/thebtf/contextbricks-universal
202
+ Issues: https://github.com/thebtf/contextbricks-universal/issues
203
+ `);
204
+ }
205
+
206
+ function test() {
207
+ console.log(`${c.cyan}Testing statusline with sample data...${c.reset}\n`);
208
+
209
+ const now = Date.now();
210
+ const sampleData = JSON.stringify({
211
+ model: { display_name: 'Sonnet 4.5' },
212
+ workspace: { current_dir: process.cwd() },
213
+ context_window: {
214
+ context_window_size: 200000,
215
+ used_percentage: 43.5,
216
+ remaining_percentage: 56.5,
217
+ },
218
+ cost: {
219
+ total_duration_ms: 765000,
220
+ total_cost_usd: 0.87,
221
+ total_lines_added: 145,
222
+ total_lines_removed: 23,
223
+ },
224
+ _mock_rate_limits: {
225
+ five_hour: { utilization: 64.0, resets_at: new Date(now + 23 * 60000).toISOString() },
226
+ seven_day: { utilization: 57.0, resets_at: new Date(now + 2 * 86400000).toISOString() },
227
+ seven_day_sonnet: { utilization: 9.0, resets_at: new Date(now + 4 * 86400000).toISOString() },
228
+ seven_day_opus: null,
229
+ },
230
+ });
231
+
232
+ const result = spawnSync(process.execPath, [STATUSLINE_SCRIPT], {
233
+ input: sampleData,
234
+ encoding: 'utf8',
235
+ windowsHide: true,
236
+ timeout: 10000,
237
+ });
238
+
239
+ if (result.stdout) {
240
+ process.stdout.write(result.stdout);
241
+ }
242
+ if (result.stderr) {
243
+ process.stderr.write(result.stderr);
244
+ }
245
+
246
+ console.log(`\n${c.dim}--- Test complete ---${c.reset}`);
247
+ }
248
+
249
+ // Main
250
+ switch (command) {
251
+ case 'install':
252
+ case 'init':
253
+ install();
254
+ break;
255
+
256
+ case 'uninstall':
257
+ uninstall();
258
+ break;
259
+
260
+ case 'test':
261
+ test();
262
+ break;
263
+
264
+ case '--version':
265
+ case '-v': {
266
+ const pkg = require('../package.json');
267
+ console.log(`contextbricks-universal v${pkg.version}`);
268
+ break;
269
+ }
270
+
271
+ case '--help':
272
+ case '-h':
273
+ case 'help':
274
+ showHelp();
275
+ break;
276
+
277
+ default:
278
+ if (command) {
279
+ console.error(`${c.red}Unknown command: ${command}${c.reset}\n`);
280
+ showHelp();
281
+ process.exit(1);
282
+ } else {
283
+ install();
284
+ }
285
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "contextbricks-universal",
3
+ "version": "4.2.0",
4
+ "description": "Universal cross-platform statusline for Claude Code CLI with context brick visualization. Windows, Linux, macOS. No bash or jq required.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "statusline",
9
+ "cli",
10
+ "context",
11
+ "bricks",
12
+ "git",
13
+ "tokens",
14
+ "terminal",
15
+ "visualization",
16
+ "windows",
17
+ "cross-platform"
18
+ ],
19
+ "bin": {
20
+ "contextbricks": "./bin/cli.js",
21
+ "contextbricks-universal": "./bin/cli.js"
22
+ },
23
+ "main": "./scripts/statusline.js",
24
+ "scripts": {
25
+ "test": "node bin/cli.js test",
26
+ "postinstall": "node bin/cli.js install"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "scripts/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=14"
36
+ },
37
+ "os": [
38
+ "win32",
39
+ "linux",
40
+ "darwin"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/thebtf/contextbricks-universal.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/thebtf/contextbricks-universal/issues"
48
+ },
49
+ "contributors": [
50
+ "Jeremy Dawes <jeremy@jezweb.net> (https://jezweb.com.au)"
51
+ ],
52
+ "license": "MIT",
53
+ "homepage": "https://github.com/thebtf/contextbricks-universal"
54
+ }
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Claude Code Custom Status Line (Node.js / Cross-Platform)
4
+ // v4.2.0 - Node.js rewrite for Windows + Linux + macOS
5
+ // Line 1: Model | Repo:Branch [subdir] | git status | lines changed
6
+ // Line 2: [commit] commit message
7
+ // Line 3: Context bricks | percentage | free | duration | cost
8
+ // Line 4: Rate limit utilization (5h, 7d Sonnet, 7d Opus) — Max/Pro subscribers
9
+ //
10
+ // Configuration via environment variables:
11
+ // CONTEXTBRICKS_SHOW_DIR=1 Show current subdirectory (default: 1)
12
+ // CONTEXTBRICKS_SHOW_DIR=0 Hide subdirectory
13
+ // CONTEXTBRICKS_BRICKS=40 Number of bricks (default: 30)
14
+ // CONTEXTBRICKS_SHOW_LIMITS=0 Hide rate limit line (default: shown)
15
+ // CONTEXTBRICKS_RESET_EXACT=0 Approximate reset times (default: exact)
16
+ //
17
+ // Uses new percentage fields (Claude Code 2.1.6+) for accurate context display.
18
+ // Falls back to current_usage calculation for older versions.
19
+ // See: https://code.claude.com/docs/en/statusline
20
+
21
+ 'use strict';
22
+
23
+ const { spawnSync } = require('child_process');
24
+ const path = require('path');
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+
28
+ const MAX_STDIN_BYTES = 1024 * 1024; // 1MB safety limit
29
+
30
+ // Read all stdin synchronously
31
+ function readStdin() {
32
+ const chunks = [];
33
+ const BUFSIZE = 4096;
34
+ const buf = Buffer.alloc(BUFSIZE);
35
+ let totalRead = 0;
36
+
37
+ try {
38
+ const fd = process.stdin.fd;
39
+ while (totalRead < MAX_STDIN_BYTES) {
40
+ try {
41
+ const bytesRead = fs.readSync(fd, buf, 0, BUFSIZE, null);
42
+ if (bytesRead === 0) break;
43
+ chunks.push(Buffer.from(buf.slice(0, bytesRead)));
44
+ totalRead += bytesRead;
45
+ } catch {
46
+ break; // EAGAIN or EOF
47
+ }
48
+ }
49
+ } catch {
50
+ // fd not readable
51
+ }
52
+
53
+ return Buffer.concat(chunks).toString('utf8');
54
+ }
55
+
56
+ // Resolve the working directory for git commands
57
+ function resolveGitCwd(currentDir) {
58
+ try {
59
+ if (currentDir && fs.statSync(currentDir).isDirectory()) {
60
+ return currentDir;
61
+ }
62
+ } catch {
63
+ // invalid path
64
+ }
65
+ return process.cwd();
66
+ }
67
+
68
+ // Run a git command using spawnSync (no shell injection possible)
69
+ function git(args, cwd, fallback = '') {
70
+ try {
71
+ const result = spawnSync('git', args, {
72
+ encoding: 'utf8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ timeout: 5000,
75
+ windowsHide: true,
76
+ cwd,
77
+ });
78
+ if (result.status === 0 && result.stdout) {
79
+ return result.stdout.trim();
80
+ }
81
+ return fallback;
82
+ } catch {
83
+ return fallback;
84
+ }
85
+ }
86
+
87
+ // Safely traverse nested object path like 'a.b.c'
88
+ function getPath(obj, dotPath) {
89
+ const parts = dotPath.split('.');
90
+ let current = obj;
91
+ for (const part of parts) {
92
+ if (current == null || typeof current !== 'object') return undefined;
93
+ current = current[part];
94
+ }
95
+ return current;
96
+ }
97
+
98
+ // ANSI color helpers
99
+ const c = {
100
+ reset: '\x1b[0m',
101
+ bold: '\x1b[1m',
102
+ dim: '\x1b[2m',
103
+ cyan: '\x1b[1;36m',
104
+ green: '\x1b[1;32m',
105
+ blue: '\x1b[1;34m',
106
+ red: '\x1b[1;31m',
107
+ yellow: '\x1b[1;33m',
108
+ dimWhite: '\x1b[2;37m',
109
+ greenNorm: '\x1b[0;32m',
110
+ redNorm: '\x1b[0;31m',
111
+ cyanNorm: '\x1b[0;36m',
112
+ yellowNorm: '\x1b[0;33m',
113
+ };
114
+
115
+ // Read OAuth token from Claude Code credentials
116
+ function readOAuthToken() {
117
+ try {
118
+ if (process.platform === 'darwin') {
119
+ // macOS: try keychain first
120
+ const result = spawnSync('security', [
121
+ 'find-generic-password',
122
+ '-s', 'Claude Code-credentials',
123
+ '-w',
124
+ ], {
125
+ encoding: 'utf8',
126
+ stdio: ['pipe', 'pipe', 'pipe'],
127
+ timeout: 3000,
128
+ windowsHide: true,
129
+ });
130
+ if (result.status === 0 && result.stdout) {
131
+ try {
132
+ const creds = JSON.parse(result.stdout.trim());
133
+ const token = getPath(creds, 'claudeAiOauth.accessToken');
134
+ if (token) return token;
135
+ } catch {
136
+ // keychain data not valid JSON, fall through to file
137
+ }
138
+ }
139
+ }
140
+
141
+ // Win/Linux (and macOS fallback): read credentials file
142
+ const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
143
+ const raw = fs.readFileSync(credPath, 'utf8');
144
+ const creds = JSON.parse(raw);
145
+ return getPath(creds, 'claudeAiOauth.accessToken') || null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ // Fetch usage data from Anthropic API with file-based caching
152
+ function fetchUsageData(token, input) {
153
+ // Check for mock data in input (test mode)
154
+ const mockData = getPath(input, '_mock_rate_limits');
155
+ if (mockData) return mockData;
156
+
157
+ if (!token) return null;
158
+
159
+ const cacheFile = path.join(os.homedir(), '.claude', '.usage-cache.json');
160
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
161
+
162
+ // Try cache first
163
+ try {
164
+ const cacheRaw = fs.readFileSync(cacheFile, 'utf8');
165
+ const cache = JSON.parse(cacheRaw);
166
+ if (cache.timestamp && (Date.now() - cache.timestamp) < CACHE_TTL_MS) {
167
+ return cache.data;
168
+ }
169
+ } catch {
170
+ // no cache or invalid
171
+ }
172
+
173
+ // Fetch from API using sync subprocess (token via env var for security)
174
+ const httpsScript = `
175
+ const https = require('https');
176
+ const options = {
177
+ hostname: 'api.anthropic.com',
178
+ path: '/api/oauth/usage',
179
+ method: 'GET',
180
+ headers: {
181
+ 'Authorization': 'Bearer ' + process.env.ANTHROPIC_TOKEN,
182
+ 'anthropic-beta': 'oauth-2025-04-20',
183
+ 'Accept': 'application/json',
184
+ },
185
+ };
186
+ const req = https.request(options, (res) => {
187
+ let body = '';
188
+ res.on('data', (chunk) => body += chunk);
189
+ res.on('end', () => {
190
+ if (res.statusCode === 200) {
191
+ process.stdout.write(body);
192
+ }
193
+ });
194
+ });
195
+ req.on('error', () => {});
196
+ req.end();
197
+ `;
198
+
199
+ try {
200
+ const result = spawnSync(process.execPath, ['-e', httpsScript], {
201
+ encoding: 'utf8',
202
+ stdio: ['pipe', 'pipe', 'pipe'],
203
+ timeout: 4000,
204
+ windowsHide: true,
205
+ env: { ...process.env, ANTHROPIC_TOKEN: token },
206
+ });
207
+
208
+ if (result.status === 0 && result.stdout) {
209
+ const data = JSON.parse(result.stdout);
210
+ // Only cache valid usage data (not error responses)
211
+ if (data && (data.five_hour || data.seven_day || data.seven_day_sonnet || data.seven_day_opus)) {
212
+ try {
213
+ fs.writeFileSync(cacheFile, JSON.stringify({ timestamp: Date.now(), data }), {
214
+ encoding: 'utf8',
215
+ mode: 0o600,
216
+ });
217
+ } catch {
218
+ // cache write failure is non-fatal
219
+ }
220
+ }
221
+ return data;
222
+ }
223
+ } catch {
224
+ // API call failed — try stale cache
225
+ try {
226
+ const cacheRaw = fs.readFileSync(cacheFile, 'utf8');
227
+ const cache = JSON.parse(cacheRaw);
228
+ if (cache.data) return cache.data;
229
+ } catch {
230
+ // no stale cache
231
+ }
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ // Return 256-color ANSI code for smooth green → yellow → red gradient
238
+ function getColorForUtilization(pct) {
239
+ // 256-color: green(46) → yellow(226) → red(196), 11 stops at ~10% intervals
240
+ const gradient = [46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196];
241
+ const clamped = Math.max(0, Math.min(100, pct));
242
+ const idx = Math.min(Math.round(clamped / 10), gradient.length - 1);
243
+ return `\x1b[38;5;${gradient[idx]}m`;
244
+ }
245
+
246
+ // Format ISO reset time string to human-readable relative time
247
+ // exact=true: "1h30m", "2d5h" | exact=false: "1h", "2d"
248
+ function formatResetTime(isoStr, exact) {
249
+ if (!isoStr) return '';
250
+ try {
251
+ const resetMs = new Date(isoStr).getTime();
252
+ const diffMs = resetMs - Date.now();
253
+ if (diffMs <= 0) return '0m';
254
+
255
+ const totalMin = Math.floor(diffMs / 60000);
256
+ const totalHours = Math.floor(totalMin / 60);
257
+ const remainMin = totalMin % 60;
258
+ const days = Math.floor(totalHours / 24);
259
+ const remainHours = totalHours % 24;
260
+
261
+ if (!exact) {
262
+ if (totalMin < 60) return `${totalMin}m`;
263
+ if (totalHours < 24) return `${totalHours}h`;
264
+ return `${days}d`;
265
+ }
266
+
267
+ // Exact mode: combined units
268
+ if (totalMin < 60) return `${totalMin}m`;
269
+ if (totalHours < 24) {
270
+ return remainMin > 0 ? `${totalHours}h${remainMin}m` : `${totalHours}h`;
271
+ }
272
+ return remainHours > 0 ? `${days}d${remainHours}h` : `${days}d`;
273
+ } catch {
274
+ return '';
275
+ }
276
+ }
277
+
278
+ // Build a single rate limit segment like "5h:23% ~1h30m"
279
+ function buildLimitSegment(data, key, label, exact) {
280
+ const pct = getPath(data, `${key}.utilization`);
281
+ if (pct == null) return null;
282
+ const color = getColorForUtilization(pct);
283
+ const resetStr = formatResetTime(getPath(data, `${key}.resets_at`), exact);
284
+ let segment = `${c.dimWhite}${label}:${c.reset}${color}${Math.round(pct)}%${c.reset}`;
285
+ if (resetStr) segment += ` ${c.dim}~${resetStr}${c.reset}`;
286
+ return segment;
287
+ }
288
+
289
+ // Assemble rate limit Line 4 from usage data
290
+ function formatRateLimitLine(data) {
291
+ if (!data) return '';
292
+ const exact = process.env.CONTEXTBRICKS_RESET_EXACT !== '0'; // default: exact
293
+ const segments = [
294
+ buildLimitSegment(data, 'five_hour', '5h', exact),
295
+ buildLimitSegment(data, 'seven_day', '7d', exact),
296
+ buildLimitSegment(data, 'seven_day_sonnet', 'sonnet', exact),
297
+ buildLimitSegment(data, 'seven_day_opus', 'opus', exact),
298
+ ].filter(Boolean);
299
+ return segments.length > 0 ? segments.join(' | ') : '';
300
+ }
301
+
302
+ function main() {
303
+ // Read JSON from stdin
304
+ const raw = readStdin();
305
+ if (!raw) {
306
+ process.stdout.write('ContextBricks: no input\n');
307
+ return;
308
+ }
309
+
310
+ let input;
311
+ try {
312
+ input = JSON.parse(raw);
313
+ } catch {
314
+ process.stdout.write('ContextBricks: invalid JSON\n');
315
+ return;
316
+ }
317
+
318
+ // Parse Claude data
319
+ const model = (getPath(input, 'model.display_name') || 'Claude').replace('Claude ', '');
320
+ const currentDir = getPath(input, 'workspace.current_dir') || process.cwd();
321
+ const linesAdded = Number(getPath(input, 'cost.total_lines_added')) || 0;
322
+ const linesRemoved = Number(getPath(input, 'cost.total_lines_removed')) || 0;
323
+
324
+ // Configuration from environment variables
325
+ const showDir = process.env.CONTEXTBRICKS_SHOW_DIR !== '0'; // default: on
326
+ const totalBricks = Math.max(1, Number(process.env.CONTEXTBRICKS_BRICKS) || 30);
327
+
328
+ // Resolve working directory (no process.chdir — pass cwd to git instead)
329
+ const cwd = resolveGitCwd(currentDir);
330
+
331
+ // Get git information
332
+ let repoName = '';
333
+ let branch = '';
334
+ let commitShort = '';
335
+ let commitMsg = '';
336
+ let gitStatus = '';
337
+ let subDir = ''; // path relative to repo root
338
+
339
+ const gitDir = git(['rev-parse', '--git-dir'], cwd);
340
+ if (gitDir) {
341
+ const toplevel = git(['rev-parse', '--show-toplevel'], cwd);
342
+ repoName = toplevel ? path.basename(toplevel) : '';
343
+ branch = git(['branch', '--show-current'], cwd) || 'detached';
344
+
345
+ // Compute subdirectory relative to repo root
346
+ if (showDir && toplevel) {
347
+ const rel = path.relative(toplevel, cwd).replace(/\\/g, '/');
348
+ if (rel && rel !== '.') {
349
+ subDir = rel;
350
+ }
351
+ }
352
+ commitShort = git(['rev-parse', '--short', 'HEAD'], cwd);
353
+
354
+ // Fetch commit message once, reuse for both lines
355
+ commitMsg = git(['log', '-1', '--pretty=format:%s'], cwd);
356
+
357
+ // Git status indicators
358
+ const porcelain = git(['status', '--porcelain'], cwd);
359
+ if (porcelain) {
360
+ gitStatus = '*';
361
+ }
362
+
363
+ // Check ahead/behind remote
364
+ const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd);
365
+ if (upstream) {
366
+ const ahead = Number(git(['rev-list', '--count', `${upstream}..HEAD`], cwd)) || 0;
367
+ const behind = Number(git(['rev-list', '--count', `HEAD..${upstream}`], cwd)) || 0;
368
+ if (ahead > 0) gitStatus += `\u2191${ahead}`;
369
+ if (behind > 0) gitStatus += `\u2193${behind}`;
370
+ }
371
+ }
372
+
373
+ // === Build Line 1: Model + Repo:Branch + Status + Changes ===
374
+ let line1 = '';
375
+
376
+ // Model in brackets
377
+ line1 += `${c.cyan}[${model}]${c.reset} `;
378
+
379
+ // Repo:Branch + subdirectory
380
+ if (repoName) {
381
+ line1 += `${c.green}${repoName}${c.reset}`;
382
+ if (branch) {
383
+ line1 += `:${c.blue}${branch}${c.reset}`;
384
+ }
385
+ if (subDir) {
386
+ line1 += ` ${c.dim}${subDir}${c.reset}`;
387
+ }
388
+ } else if (showDir) {
389
+ // No git repo — show tilde-compressed path
390
+ const home = os.homedir();
391
+ let displayPath = cwd.replace(/\\/g, '/');
392
+ const homeNorm = home.replace(/\\/g, '/');
393
+ if (displayPath.startsWith(homeNorm)) {
394
+ displayPath = '~' + displayPath.slice(homeNorm.length);
395
+ }
396
+ line1 += `${c.dim}${displayPath}${c.reset}`;
397
+ }
398
+
399
+ // Git status indicators
400
+ if (gitStatus) {
401
+ line1 += ` ${c.red}${gitStatus}${c.reset}`;
402
+ }
403
+
404
+ // Lines changed
405
+ if (linesAdded > 0 || linesRemoved > 0) {
406
+ line1 += ` | ${c.greenNorm}+${linesAdded}${c.reset}/${c.redNorm}-${linesRemoved}${c.reset}`;
407
+ }
408
+
409
+ // === Build Line 2: Commit hash + message ===
410
+ let line2 = '';
411
+ if (commitShort) {
412
+ line2 += `${c.yellow}[${commitShort}]${c.reset}`;
413
+ if (commitMsg) {
414
+ const truncatedMsg = commitMsg.length > 55 ? commitMsg.substring(0, 55) + '...' : commitMsg;
415
+ line2 += ` ${truncatedMsg}`;
416
+ }
417
+ }
418
+
419
+ // === Build Line 3: Context bricks + session info ===
420
+
421
+ // Session duration
422
+ const durationMs = Number(getPath(input, 'cost.total_duration_ms')) || 0;
423
+ const durationHours = Math.floor(durationMs / 3600000);
424
+ const durationMin = Math.floor((durationMs % 3600000) / 60000);
425
+
426
+ // Session cost
427
+ const costUsd = Number(getPath(input, 'cost.total_cost_usd')) || 0;
428
+
429
+ // Context window data
430
+ const totalTokens = Number(getPath(input, 'context_window.context_window_size')) || 200000;
431
+
432
+ // Try new percentage fields first (Claude Code 2.1.6+)
433
+ const usedPctRaw = getPath(input, 'context_window.used_percentage');
434
+ const remainingPctRaw = getPath(input, 'context_window.remaining_percentage');
435
+
436
+ let usagePct, usedTokens, freeTokens;
437
+
438
+ if (usedPctRaw != null && usedPctRaw !== '') {
439
+ // Use official percentage (more accurate)
440
+ usagePct = Math.floor(Number(usedPctRaw));
441
+ const remainingPct = Math.floor(Number(remainingPctRaw) || (100 - usagePct));
442
+ usedTokens = Math.floor((totalTokens * usagePct) / 100);
443
+ freeTokens = Math.floor((totalTokens * remainingPct) / 100);
444
+ } else {
445
+ // Fallback: Calculate from current_usage (Claude Code 2.0.70+)
446
+ const currentUsage = getPath(input, 'context_window.current_usage');
447
+
448
+ if (currentUsage && typeof currentUsage === 'object') {
449
+ const inputTokens = Number(currentUsage.input_tokens) || 0;
450
+ const cacheCreation = Number(currentUsage.cache_creation_input_tokens) || 0;
451
+ const cacheRead = Number(currentUsage.cache_read_input_tokens) || 0;
452
+ usedTokens = inputTokens + cacheCreation + cacheRead;
453
+ } else {
454
+ usedTokens = 0;
455
+ }
456
+
457
+ freeTokens = totalTokens - usedTokens;
458
+ usagePct = totalTokens > 0 ? Math.floor((usedTokens * 100) / totalTokens) : 0;
459
+ }
460
+
461
+ // Convert to 'k' format
462
+ const freeK = Math.floor(freeTokens / 1000);
463
+
464
+ // Generate brick visualization
465
+ const usedBricks = totalTokens > 0 ? Math.floor((usedTokens * totalBricks) / totalTokens) : 0;
466
+ const freeBricks = totalBricks - usedBricks;
467
+
468
+ // Build brick line
469
+ let brickLine = '[';
470
+
471
+ // Used bricks (cyan)
472
+ for (let i = 0; i < usedBricks; i++) {
473
+ brickLine += `${c.cyanNorm}\u25A0${c.reset}`;
474
+ }
475
+
476
+ // Free bricks (dim/gray hollow squares)
477
+ for (let i = 0; i < freeBricks; i++) {
478
+ brickLine += `${c.dimWhite}\u25A1${c.reset}`;
479
+ }
480
+
481
+ brickLine += ']';
482
+
483
+ // Compact stats
484
+ brickLine += ` ${c.bold}${usagePct}%${c.reset}`;
485
+ brickLine += ` | ${c.greenNorm}${freeK}k free${c.reset}`;
486
+ brickLine += ` | ${durationHours}h${durationMin}m`;
487
+
488
+ // Cost (only if non-zero)
489
+ if (costUsd > 0) {
490
+ const costFormatted = costUsd.toFixed(2);
491
+ brickLine += ` | ${c.yellowNorm}$${costFormatted}${c.reset}`;
492
+ }
493
+
494
+ // Output all lines
495
+ process.stdout.write(line1 + '\n');
496
+ if (line2) {
497
+ process.stdout.write(line2 + '\n');
498
+ }
499
+ process.stdout.write(brickLine + '\n');
500
+
501
+ // Line 4: Rate limit utilization
502
+ const showLimits = process.env.CONTEXTBRICKS_SHOW_LIMITS !== '0';
503
+ if (showLimits) {
504
+ const token = readOAuthToken();
505
+ const usageData = fetchUsageData(token, input);
506
+ const line4 = formatRateLimitLine(usageData);
507
+ if (line4) {
508
+ process.stdout.write(line4 + '\n');
509
+ }
510
+ }
511
+ }
512
+
513
+ main();