ctxline-claude 0.0.1
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 +21 -0
- package/README.md +167 -0
- package/bin/install.js +135 -0
- package/package.json +39 -0
- package/preview.png +0 -0
- package/statusline.js +345 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mithun Wijayasiri
|
|
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,167 @@
|
|
|
1
|
+
# Claude Code Enhanced Statusline
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A real-time statusline for [Claude Code](https://github.com/anthropics/claude-code) that shows your current directory, model, context window usage, and session token limits at a glance. The usage bar pulls directly from Anthropic's API, the same data you get from `/usage`, so it's always accurate. One script, zero config. Auto-detects subscription vs API key.
|
|
6
|
+
|
|
7
|
+
## Why This Statusline
|
|
8
|
+
|
|
9
|
+
Two metrics have the biggest impact on a Claude Code session: **context window usage** and **remaining session usage**. Model output quality tends to degrade as the context window fills (often called "context rot"), so keeping an eye on it helps you compact or reset at the right time. Session usage tells you how much of your current limit remains, so you can plan work before hitting a reset. This statusline surfaces both at a glance, letting you stay focused on the task rather than guessing where you stand.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- [Claude Code](https://github.com/anthropics/claude-code) installed
|
|
14
|
+
- Node.js (comes with Claude Code)
|
|
15
|
+
- Authenticated Claude account (for usage tracking on subscription plans)
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### Quick Install (Recommended)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx ctxline-claude
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or with bun:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bunx ctxline-claude
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Restart Claude Code or start a new session.
|
|
32
|
+
|
|
33
|
+
### Clone & Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/MithunWijayasiri/claudecode-statusline.git
|
|
37
|
+
cd claudecode-statusline
|
|
38
|
+
|
|
39
|
+
# macOS / Linux
|
|
40
|
+
./install.sh
|
|
41
|
+
|
|
42
|
+
# Windows (PowerShell)
|
|
43
|
+
./install.ps1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Manual Install
|
|
47
|
+
|
|
48
|
+
1. **Download the script:**
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
curl -o ~/.claude/hooks/statusline.js https://raw.githubusercontent.com/MithunWijayasiri/claudecode-statusline/main/statusline.js
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
2. **Make it executable:**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
chmod +x ~/.claude/hooks/statusline.js
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
3. **Update Claude Code settings:**
|
|
61
|
+
|
|
62
|
+
Edit `~/.claude/settings.json` and add/modify the `statusLine` section:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"statusLine": {
|
|
67
|
+
"type": "command",
|
|
68
|
+
"command": "node ~/.claude/hooks/statusline.js"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
4. **Restart Claude Code** or start a new session.
|
|
74
|
+
|
|
75
|
+
## Uninstall
|
|
76
|
+
|
|
77
|
+
### Quick Uninstall (Recommended)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx ctxline-claude uninstall
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This removes the `statusLine` entry from `~/.claude/settings.json` (backing the file up first, and leaving any other settings intact), deletes `~/.claude/hooks/statusline.js`, and clears the cached usage data. Restart Claude Code afterward. If `settings.json` points to a different statusline, it's left untouched.
|
|
84
|
+
|
|
85
|
+
### Manual Uninstall
|
|
86
|
+
|
|
87
|
+
The installer only does two things: it copies `statusline.js` into `~/.claude/hooks/` and adds a `statusLine` block to `~/.claude/settings.json`. To fully remove it, undo both.
|
|
88
|
+
|
|
89
|
+
1. **Remove the `statusLine` block from `~/.claude/settings.json`.**
|
|
90
|
+
|
|
91
|
+
Open the file and delete this section:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
"statusLine": {
|
|
95
|
+
"type": "command",
|
|
96
|
+
"command": "node ~/.claude/hooks/statusline.js"
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
(The installer saved a timestamped backup, e.g. `settings.json.backup.<number>`, if you'd rather restore that.)
|
|
101
|
+
|
|
102
|
+
2. **Delete the script (and optional cache):**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# macOS / Linux
|
|
106
|
+
rm ~/.claude/hooks/statusline.js
|
|
107
|
+
rm -f ~/.claude/cache/usage-cache.json # optional: clears cached usage data
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
# Windows (PowerShell)
|
|
112
|
+
Remove-Item "$env:USERPROFILE\.claude\hooks\statusline.js"
|
|
113
|
+
Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction SilentlyContinue
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
3. **Restart Claude Code** or start a new session. The statusline is gone.
|
|
117
|
+
|
|
118
|
+
## Features
|
|
119
|
+
|
|
120
|
+
- **Context Usage**: Visual bar showing token usage (green → yellow → orange → red)
|
|
121
|
+
- **API Usage**: Real-time 5-hour session limit tracking with countdown timer (subscription users)
|
|
122
|
+
- **Current Directory**: Shows your working directory
|
|
123
|
+
- **Model Name**: Displays which Claude model you're using (Opus, Sonnet, Haiku)
|
|
124
|
+
- **Auto-Detection**: Detects API key vs subscription and adapts automatically
|
|
125
|
+
- **Adaptive Performance**: Fast after first prompt (1.2s vs 1.5s)
|
|
126
|
+
- **Smart Caching**: Shares usage data across sessions, fallback on API timeout
|
|
127
|
+
|
|
128
|
+
**Color Coding:**
|
|
129
|
+
|
|
130
|
+
- 🟢 Green: < 50% usage
|
|
131
|
+
- 🟡 Yellow: 50-75% usage
|
|
132
|
+
- 🟠 Orange: 75-90% usage
|
|
133
|
+
- 🔴 Red (blinking): > 90% usage
|
|
134
|
+
|
|
135
|
+
## How It Works
|
|
136
|
+
|
|
137
|
+
### Adaptive Timing
|
|
138
|
+
|
|
139
|
+
- **First prompt**: Uses 1500ms timeout (cold start, OAuth validation)
|
|
140
|
+
- **Subsequent prompts**: Uses 1200ms timeout (faster, connection reused)
|
|
141
|
+
- **Result**: Smooth experience after initial setup
|
|
142
|
+
|
|
143
|
+
### Caching System
|
|
144
|
+
|
|
145
|
+
- Usage data is cached in `~/.claude/cache/usage-cache.json`, shared across all sessions
|
|
146
|
+
- **Cache-first**: within 30 seconds the cache is used directly and the API call is skipped (faster renders, fewer calls)
|
|
147
|
+
- **Stale fallback**: if a live API call fails or times out, the last known usage (up to 10 minutes old) is shown instead of disappearing
|
|
148
|
+
- The reset countdown is recomputed on every render, so it keeps ticking even when shown from cache
|
|
149
|
+
|
|
150
|
+
### API Usage
|
|
151
|
+
|
|
152
|
+
- Fetches from `https://api.anthropic.com/api/oauth/usage`
|
|
153
|
+
- Tracks 5-hour session limits
|
|
154
|
+
- Shows percentage used + time until reset
|
|
155
|
+
- Fails gracefully (no statusline breakage)
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
160
|
+
|
|
161
|
+
## Credits
|
|
162
|
+
|
|
163
|
+
Originally created by [@TahaSabir0](https://github.com/TahaSabir0).
|
|
164
|
+
|
|
165
|
+
Built for the [Claude Code](https://github.com/anthropics/claude-code) community.
|
|
166
|
+
|
|
167
|
+
---
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
8
|
+
const hooksDir = path.join(claudeDir, 'hooks');
|
|
9
|
+
const settingsFile = path.join(claudeDir, 'settings.json');
|
|
10
|
+
const scriptDest = path.join(hooksDir, 'statusline.js');
|
|
11
|
+
const scriptSrc = path.join(__dirname, '..', 'statusline.js');
|
|
12
|
+
|
|
13
|
+
const green = '\x1b[32m';
|
|
14
|
+
const red = '\x1b[31m';
|
|
15
|
+
const yellow = '\x1b[33m';
|
|
16
|
+
const cyan = '\x1b[36m';
|
|
17
|
+
const reset = '\x1b[0m';
|
|
18
|
+
|
|
19
|
+
// Uninstall mode: `npx ctxline-claude uninstall` (additive — plain install is unchanged)
|
|
20
|
+
const mode = (process.argv[2] || '').toLowerCase();
|
|
21
|
+
if (mode === 'uninstall' || mode === 'remove') {
|
|
22
|
+
runUninstall();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log(`${cyan}======================================${reset}`);
|
|
27
|
+
console.log(`${cyan} Claude Code Statusline Installer${reset}`);
|
|
28
|
+
console.log(`${cyan}======================================${reset}\n`);
|
|
29
|
+
|
|
30
|
+
// Check Claude Code is installed
|
|
31
|
+
if (!fs.existsSync(claudeDir)) {
|
|
32
|
+
console.log(`${red}Error: Claude Code not found!${reset}`);
|
|
33
|
+
console.log('Please install Claude Code first: https://github.com/anthropics/claude-code');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Create hooks directory
|
|
38
|
+
if (!fs.existsSync(hooksDir)) {
|
|
39
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Copy statusline script
|
|
43
|
+
console.log(`${yellow}Installing statusline...${reset}`);
|
|
44
|
+
fs.copyFileSync(scriptSrc, scriptDest);
|
|
45
|
+
fs.chmodSync(scriptDest, 0o755);
|
|
46
|
+
console.log(`${green}✓ Installed statusline.js${reset}`);
|
|
47
|
+
|
|
48
|
+
// Update settings.json
|
|
49
|
+
console.log(`${yellow}Updating settings...${reset}`);
|
|
50
|
+
|
|
51
|
+
let settings = {};
|
|
52
|
+
if (fs.existsSync(settingsFile)) {
|
|
53
|
+
// Backup existing settings
|
|
54
|
+
const backup = `${settingsFile}.backup.${Date.now()}`;
|
|
55
|
+
fs.copyFileSync(settingsFile, backup);
|
|
56
|
+
console.log(`${green}✓ Backed up existing settings${reset}`);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
settings = {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
settings.statusLine = {
|
|
66
|
+
type: 'command',
|
|
67
|
+
command: `node ${scriptDest.replace(/\\/g, '/')}`
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
|
71
|
+
console.log(`${green}✓ Updated settings.json${reset}`);
|
|
72
|
+
|
|
73
|
+
console.log(`\n${green}======================================${reset}`);
|
|
74
|
+
console.log(`${green} Installation Complete!${reset}`);
|
|
75
|
+
console.log(`${green}======================================${reset}`);
|
|
76
|
+
console.log('\nRestart Claude Code or start a new session.');
|
|
77
|
+
console.log('The statusline will auto-detect your setup (subscription vs API key).\n');
|
|
78
|
+
|
|
79
|
+
function runUninstall() {
|
|
80
|
+
console.log(`${cyan}======================================${reset}`);
|
|
81
|
+
console.log(`${cyan} Claude Code Statusline Uninstaller${reset}`);
|
|
82
|
+
console.log(`${cyan}======================================${reset}\n`);
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(claudeDir)) {
|
|
85
|
+
console.log(`${yellow}Nothing to remove — ~/.claude was not found.${reset}\n`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 1. Remove our statusLine entry from settings.json (preserving everything else)
|
|
90
|
+
if (fs.existsSync(settingsFile)) {
|
|
91
|
+
try {
|
|
92
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
93
|
+
const command = ((settings.statusLine && settings.statusLine.command) || '').replace(/\\/g, '/');
|
|
94
|
+
// Match our hook path only (covers absolute + manual `~` installs), not any statusline.js.
|
|
95
|
+
const isOurs = command.includes('/.claude/hooks/statusline.js');
|
|
96
|
+
if (settings.statusLine && isOurs) {
|
|
97
|
+
const backup = `${settingsFile}.backup.${Date.now()}`;
|
|
98
|
+
fs.copyFileSync(settingsFile, backup);
|
|
99
|
+
delete settings.statusLine;
|
|
100
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
|
101
|
+
console.log(`${green}✓ Removed statusLine from settings.json (backup: ${path.basename(backup)})${reset}`);
|
|
102
|
+
} else if (settings.statusLine) {
|
|
103
|
+
console.log(`${yellow}! settings.json has a different statusLine — leaving it untouched.${reset}`);
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`${green}✓ No statusLine entry in settings.json${reset}`);
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.log(`${red}✗ Could not parse settings.json — remove the "statusLine" block manually.${reset}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Delete the hook script
|
|
113
|
+
if (fs.existsSync(scriptDest)) {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(scriptDest);
|
|
116
|
+
console.log(`${green}✓ Deleted ${scriptDest}${reset}`);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.log(`${red}✗ Could not delete ${scriptDest}: ${e.message}${reset}`);
|
|
119
|
+
console.log(`${yellow} Remove it manually.${reset}`);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
console.log(`${green}✓ No statusline.js found in hooks${reset}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. Clear cached usage data (best-effort)
|
|
126
|
+
const cacheFile = path.join(claudeDir, 'cache', 'usage-cache.json');
|
|
127
|
+
if (fs.existsSync(cacheFile)) {
|
|
128
|
+
try {
|
|
129
|
+
fs.unlinkSync(cacheFile);
|
|
130
|
+
console.log(`${green}✓ Cleared usage cache${reset}`);
|
|
131
|
+
} catch (e) {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`\n${green}Uninstall complete.${reset} Restart Claude Code or start a new session.\n`);
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ctxline-claude",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A customizable statusline for Claude Code that tracks context usage and session limits",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ctxline-claude": "bin/install.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test",
|
|
10
|
+
"preview": "node scripts/preview.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"statusline.js",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"preview.png"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"statusline",
|
|
26
|
+
"vibe-coding",
|
|
27
|
+
"context-window"
|
|
28
|
+
],
|
|
29
|
+
"author": "Mithun Wijayasiri",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://github.com/MithunWijayasiri/claudecode-statusline#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/MithunWijayasiri/claudecode-statusline/issues"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/MithunWijayasiri/claudecode-statusline.git"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/preview.png
ADDED
|
Binary file
|
package/statusline.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code Enhanced Statusline
|
|
3
|
+
// Shows: directory | model | context usage | API usage (5-hour limit) | current task
|
|
4
|
+
// Auto-detects API key vs subscription usage
|
|
5
|
+
// https://github.com/MithunWijayasiri/claudecode-statusline
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
|
|
14
|
+
|
|
15
|
+
// Cache configuration
|
|
16
|
+
const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
|
|
17
|
+
const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
|
|
18
|
+
// Fresh: trust the cache and skip the API call entirely (fewer calls, faster render).
|
|
19
|
+
const FRESH_TTL_MS = 30000; // 30 seconds
|
|
20
|
+
// Stale: used only as a fallback when a live API call fails, so the usage bar stays
|
|
21
|
+
// visible through transient timeouts/errors instead of disappearing.
|
|
22
|
+
const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
23
|
+
|
|
24
|
+
// ANSI color codes
|
|
25
|
+
const colors = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
bold: '\x1b[1m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
orange: '\x1b[38;5;208m',
|
|
32
|
+
red: '\x1b[31m',
|
|
33
|
+
blink: '\x1b[5m'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function getUsageColor(percentage) {
|
|
37
|
+
if (percentage < 50) return colors.green;
|
|
38
|
+
if (percentage < 75) return colors.yellow;
|
|
39
|
+
if (percentage < 90) return colors.orange;
|
|
40
|
+
return colors.red;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getContextBar(remaining) {
|
|
44
|
+
const effectiveRemaining = remaining ?? 100;
|
|
45
|
+
const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
|
|
46
|
+
|
|
47
|
+
const filled = Math.floor(used / 10);
|
|
48
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
|
49
|
+
|
|
50
|
+
let coloredBar;
|
|
51
|
+
if (used < 50) {
|
|
52
|
+
coloredBar = `${colors.green}${bar} ${used}%${colors.reset}`;
|
|
53
|
+
} else if (used < 65) {
|
|
54
|
+
coloredBar = `${colors.yellow}${bar} ${used}%${colors.reset}`;
|
|
55
|
+
} else if (used < 80) {
|
|
56
|
+
coloredBar = `${colors.orange}${bar} ${used}%${colors.reset}`;
|
|
57
|
+
} else {
|
|
58
|
+
coloredBar = `${colors.blink}${colors.red}\u{1F480} ${bar} ${used}%${colors.reset}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return coloredBar;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Render the usage bar from raw data. Called on every read (live or cached) so the
|
|
65
|
+
// reset countdown is always recomputed from resetsAt rather than frozen at fetch time.
|
|
66
|
+
function buildUsageBar(percentage, resetsAt) {
|
|
67
|
+
let timeStr = '';
|
|
68
|
+
if (resetsAt) {
|
|
69
|
+
const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
|
|
70
|
+
const hours = Math.floor(diffMins / 60);
|
|
71
|
+
const mins = diffMins % 60;
|
|
72
|
+
timeStr = hours > 0 ? `${hours}h${mins}m` : `${mins}m`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const barWidth = 10;
|
|
76
|
+
const filledWidth = Math.max(0, Math.min(barWidth, Math.round((percentage / 100) * barWidth)));
|
|
77
|
+
const filled = '█'.repeat(filledWidth);
|
|
78
|
+
const empty = '░'.repeat(barWidth - filledWidth);
|
|
79
|
+
const color = getUsageColor(percentage);
|
|
80
|
+
|
|
81
|
+
return `${color}${filled}${empty} ${percentage}%${colors.reset}${colors.dim} (${timeStr})${colors.reset}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Read the raw cached usage data ({ timestamp, data: { percentage, resetsAt } }).
|
|
85
|
+
// Returns { age, data } or null. Age-vs-TTL decisions are made by the caller.
|
|
86
|
+
function readCachedUsage() {
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
|
|
89
|
+
|
|
90
|
+
const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
|
|
91
|
+
if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
|
|
92
|
+
|
|
93
|
+
// Validate data (also rejects the legacy string-cache format from older versions),
|
|
94
|
+
// so callers never receive undefined/NaN percentage or an unparseable resetsAt.
|
|
95
|
+
const data = cache.data;
|
|
96
|
+
if (!data || typeof data !== 'object') return null;
|
|
97
|
+
if (!Number.isFinite(data.percentage) || data.percentage < 0 || data.percentage > 100) return null;
|
|
98
|
+
if (data.resetsAt != null && Number.isNaN(new Date(data.resetsAt).getTime())) return null;
|
|
99
|
+
|
|
100
|
+
return { age: Date.now() - cache.timestamp, data };
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Write usage data to cache (shared across all sessions)
|
|
107
|
+
function setCachedUsage(data) {
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
110
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cache = {
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
data: data
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// Silently fail
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getCredentials() {
|
|
125
|
+
// Try file first (legacy / Linux / Windows)
|
|
126
|
+
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
127
|
+
if (fs.existsSync(credsPath)) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(fs.readFileSync(credsPath, 'utf8'));
|
|
130
|
+
} catch (e) {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fallback: macOS keychain
|
|
134
|
+
if (os.platform() === 'darwin') {
|
|
135
|
+
try {
|
|
136
|
+
const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
|
|
137
|
+
return JSON.parse(raw.trim());
|
|
138
|
+
} catch (e) {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getApiUsage(callback) {
|
|
145
|
+
try {
|
|
146
|
+
// Read credentials (file or macOS keychain)
|
|
147
|
+
const creds = getCredentials();
|
|
148
|
+
if (!creds) {
|
|
149
|
+
return callback(null);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const accessToken = creds.claudeAiOauth?.accessToken;
|
|
153
|
+
|
|
154
|
+
if (!accessToken) {
|
|
155
|
+
return callback(null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
|
|
159
|
+
// API typically takes ~850ms, so 1200ms gives reasonable headroom
|
|
160
|
+
const hasCache = fs.existsSync(USAGE_CACHE_FILE);
|
|
161
|
+
const timeout = hasCache ? 1200 : 1500;
|
|
162
|
+
|
|
163
|
+
// Make API call with adaptive timeout
|
|
164
|
+
const req = https.request({
|
|
165
|
+
hostname: 'api.anthropic.com',
|
|
166
|
+
path: '/api/oauth/usage',
|
|
167
|
+
method: 'GET',
|
|
168
|
+
headers: {
|
|
169
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
'anthropic-beta': 'oauth-2025-04-20'
|
|
172
|
+
},
|
|
173
|
+
timeout: timeout
|
|
174
|
+
}, (res) => {
|
|
175
|
+
let data = '';
|
|
176
|
+
|
|
177
|
+
res.on('data', chunk => data += chunk);
|
|
178
|
+
res.on('end', () => {
|
|
179
|
+
try {
|
|
180
|
+
const usage = JSON.parse(data);
|
|
181
|
+
|
|
182
|
+
// Get 5-hour session usage
|
|
183
|
+
if (usage.five_hour) {
|
|
184
|
+
const percentage = Math.round(usage.five_hour.utilization);
|
|
185
|
+
const resetsAt = usage.five_hour.resets_at || null;
|
|
186
|
+
|
|
187
|
+
// Cache the raw data (shared across sessions); render the bar from it.
|
|
188
|
+
setCachedUsage({ percentage, resetsAt });
|
|
189
|
+
callback(buildUsageBar(percentage, resetsAt));
|
|
190
|
+
} else {
|
|
191
|
+
callback(null);
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
callback(null);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
req.on('error', () => callback(null));
|
|
200
|
+
req.on('timeout', () => {
|
|
201
|
+
req.destroy();
|
|
202
|
+
callback(null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
req.end();
|
|
206
|
+
} catch (e) {
|
|
207
|
+
callback(null);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Get usage, cache-first.
|
|
212
|
+
function getUsageWithCache(callback) {
|
|
213
|
+
const cached = readCachedUsage();
|
|
214
|
+
|
|
215
|
+
// Cache is fresh -> render it and skip the API entirely (fewer calls, faster).
|
|
216
|
+
if (cached && cached.age < FRESH_TTL_MS) {
|
|
217
|
+
return callback(buildUsageBar(cached.data.percentage, cached.data.resetsAt));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Cache is stale or missing -> refresh from the API.
|
|
221
|
+
getApiUsage((freshBar) => {
|
|
222
|
+
if (freshBar) {
|
|
223
|
+
callback(freshBar);
|
|
224
|
+
} else if (cached && cached.age < STALE_TTL_MS) {
|
|
225
|
+
// API failed/timed out, but recent cache exists -> show it instead of nothing.
|
|
226
|
+
callback(buildUsageBar(cached.data.percentage, cached.data.resetsAt));
|
|
227
|
+
} else {
|
|
228
|
+
callback(null);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getCurrentTask(sessionId) {
|
|
234
|
+
if (!sessionId) return '';
|
|
235
|
+
|
|
236
|
+
const homeDir = os.homedir();
|
|
237
|
+
const todosDir = path.join(homeDir, '.claude', 'todos');
|
|
238
|
+
|
|
239
|
+
if (!fs.existsSync(todosDir)) return '';
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const files = fs.readdirSync(todosDir)
|
|
243
|
+
.filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
|
|
244
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
|
|
245
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
246
|
+
|
|
247
|
+
if (files.length > 0) {
|
|
248
|
+
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
|
|
249
|
+
const inProgress = todos.find(t => t.status === 'in_progress');
|
|
250
|
+
if (inProgress) return inProgress.activeForm || '';
|
|
251
|
+
}
|
|
252
|
+
} catch (e) {}
|
|
253
|
+
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Main
|
|
258
|
+
function outputStatus(data, usageBar) {
|
|
259
|
+
try {
|
|
260
|
+
const model = data?.model?.display_name || 'Claude';
|
|
261
|
+
const dir = data?.workspace?.current_dir || process.cwd();
|
|
262
|
+
const dirname = path.basename(dir);
|
|
263
|
+
const sessionId = data?.session_id || '';
|
|
264
|
+
const remaining = data?.context_window?.remaining_percentage;
|
|
265
|
+
|
|
266
|
+
const contextBar = getContextBar(remaining);
|
|
267
|
+
const task = getCurrentTask(sessionId);
|
|
268
|
+
const parts = [];
|
|
269
|
+
parts.push(dirname);
|
|
270
|
+
parts.push(model);
|
|
271
|
+
parts.push(`context: ${contextBar}`);
|
|
272
|
+
|
|
273
|
+
if (usageBar) {
|
|
274
|
+
parts.push(`usage: ${usageBar}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (task) parts.push(`${colors.dim}${task}${colors.reset}`);
|
|
278
|
+
process.stdout.write(parts.join(' \u2502 '));
|
|
279
|
+
} catch (e) {
|
|
280
|
+
process.stdout.write('Status unavailable');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function outputFallback(usageBar) {
|
|
285
|
+
const contextBar = getContextBar(undefined);
|
|
286
|
+
const parts = ['~', 'Claude', `context: ${contextBar}`];
|
|
287
|
+
if (usageBar) parts.push(`usage: ${usageBar}`);
|
|
288
|
+
process.stdout.write(parts.join(' \u2502 '));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Wrapper that skips usage fetch for API key users
|
|
292
|
+
function getUsage(callback) {
|
|
293
|
+
if (IS_API_KEY) {
|
|
294
|
+
callback(null);
|
|
295
|
+
} else {
|
|
296
|
+
getUsageWithCache(callback);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Process with timeout
|
|
301
|
+
if (process.stdin.isTTY) {
|
|
302
|
+
getUsage((usageBar) => {
|
|
303
|
+
outputFallback(usageBar);
|
|
304
|
+
process.exit(0);
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
let input = '';
|
|
308
|
+
let timeoutReached = false;
|
|
309
|
+
|
|
310
|
+
const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
|
|
311
|
+
|
|
312
|
+
const timeout = setTimeout(() => {
|
|
313
|
+
timeoutReached = true;
|
|
314
|
+
getUsage((usageBar) => {
|
|
315
|
+
if (input.length > 0) {
|
|
316
|
+
try {
|
|
317
|
+
const data = JSON.parse(input);
|
|
318
|
+
outputStatus(data, usageBar);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
outputFallback(usageBar);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
outputFallback(usageBar);
|
|
324
|
+
}
|
|
325
|
+
process.exit(0);
|
|
326
|
+
});
|
|
327
|
+
}, overallTimeout);
|
|
328
|
+
|
|
329
|
+
process.stdin.setEncoding('utf8');
|
|
330
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
331
|
+
process.stdin.on('end', () => {
|
|
332
|
+
if (timeoutReached) return;
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
|
|
335
|
+
getUsage((usageBar) => {
|
|
336
|
+
try {
|
|
337
|
+
const data = JSON.parse(input);
|
|
338
|
+
outputStatus(data, usageBar);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
outputFallback(usageBar);
|
|
341
|
+
}
|
|
342
|
+
process.exit(0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
}
|