@alyibrahim/claude-statusline 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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @alyibrahim/claude-statusline
2
+
3
+ A zero-dependency statusline for [Claude Code](https://claude.ai/code). Shows model, git branch, context usage, subscription rate limits, and session cost — updating after every response.
4
+
5
+ ## Requirements
6
+
7
+ - **Node.js >=16** — the only hard requirement (installed with npm)
8
+ - **git** — optional, used for branch display; gracefully absent if not installed
9
+
10
+ No `jq`, `bc`, `ccusage`, or other external tools needed.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install -g @alyibrahim/claude-statusline
16
+ ```
17
+
18
+ That's it. The statusline is configured automatically. Restart Claude Code to see it.
19
+
20
+ **Manual setup** (if auto-setup failed):
21
+ ```bash
22
+ claude-statusline setup
23
+ ```
24
+
25
+ ## What it shows
26
+
27
+ **Line 1:** Model · Effort level · Active agents · Current task · Directory `[git branch]` · Context bar
28
+
29
+ **Line 2:** Weekly usage · 5h usage · Reset countdown *(subscription)* or Session cost *(API key)*
30
+
31
+ ## Why this one
32
+
33
+ | | This package | Others |
34
+ |---|---|---|
35
+ | Zero dependencies | ✓ no `jq`, `bc`, etc. | Require external tools |
36
+ | No API calls | ✓ reads stdin directly | Poll OAuth endpoint, hit rate limits |
37
+ | Subscription vs API aware | ✓ | Show cost for everyone |
38
+ | Context bar normalized | ✓ usable % | Raw remaining % |
39
+ | Active agent counter | ✓ | — |
40
+
41
+ ## Uninstall
42
+
43
+ ```bash
44
+ npm uninstall -g @alyibrahim/claude-statusline
45
+ ```
46
+
47
+ > If using yarn/pnpm/bun, run `claude-statusline uninstall` **before** removing the package.
48
+
49
+ ## Notes
50
+
51
+ - **Switched Node versions?** Re-run `claude-statusline setup` — the Node path is baked in at install time.
52
+ - Writes only the `statusLine` key in `~/.claude/settings.json` — all other settings are preserved.
53
+ - Respects `$CLAUDE_CONFIG_DIR` if set.
54
+
55
+ ## License
56
+
57
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { setup } = require('../scripts/setup');
4
+ const { uninstall } = require('../scripts/uninstall');
5
+ const { getSettingsPath } = require('../scripts/config');
6
+
7
+ const USAGE = `
8
+ claude-statusline <command>
9
+
10
+ Commands:
11
+ setup Configure ~/.claude/settings.json to use this statusline
12
+ uninstall Remove this statusline from ~/.claude/settings.json
13
+ `.trim();
14
+
15
+ const cmd = process.argv[2];
16
+
17
+ if (cmd === 'setup') {
18
+ const result = setup({ force: true });
19
+ if (!result.ok) {
20
+ console.error('Error:', result.error);
21
+ process.exit(1);
22
+ }
23
+ console.log(`✓ Configured at ${result.settingsPath}. Restart Claude Code to see it.`);
24
+
25
+ } else if (cmd === 'uninstall') {
26
+ const result = uninstall();
27
+ if (!result.ok) {
28
+ console.error('Error:', result.error);
29
+ process.exit(1);
30
+ }
31
+ console.log(`✓ Removed statusline from ${getSettingsPath()}`);
32
+
33
+ } else if (cmd === undefined) {
34
+ console.log(USAGE);
35
+ process.exit(0);
36
+
37
+ } else {
38
+ console.error(`Unknown command: ${cmd}`);
39
+ console.log('\n' + USAGE);
40
+ process.exit(1);
41
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@alyibrahim/claude-statusline",
3
+ "version": "1.0.0",
4
+ "description": "A zero-dependency Claude Code statusline — reads rate limits from stdin, no API calls",
5
+ "engines": { "node": ">=16" },
6
+ "bin": { "claude-statusline": "bin/cli.js" },
7
+ "scripts": {
8
+ "postinstall": "node scripts/postinstall.js",
9
+ "preuninstall": "node scripts/preuninstall.js",
10
+ "test": "jest"
11
+ },
12
+ "files": ["statusline.js", "bin/", "scripts/", "README.md"],
13
+ "license": "MIT",
14
+ "devDependencies": {
15
+ "jest": "^29.0.0"
16
+ }
17
+ }
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ function getSettingsPath() {
7
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
8
+ const base = (configDir && configDir.trim())
9
+ ? configDir
10
+ : path.join(os.homedir(), '.claude');
11
+ return path.join(base, 'settings.json');
12
+ }
13
+
14
+ function atomicWrite(filePath, obj) {
15
+ const tmpPath = filePath + '.tmp';
16
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
17
+ fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2));
18
+ try {
19
+ fs.renameSync(tmpPath, filePath);
20
+ } catch (err) {
21
+ try { fs.unlinkSync(tmpPath); } catch (e) {}
22
+ throw err;
23
+ }
24
+ }
25
+
26
+ module.exports = { getSettingsPath, atomicWrite };
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { setup } = require('./setup');
4
+ try {
5
+ const result = setup({ force: false });
6
+ if (result.settingsPath === null) process.exit(0); // non-global install, skip silently
7
+ if (!result.ok) {
8
+ console.warn('\n⚠ claude-statusline: auto-setup failed:', result.error);
9
+ console.warn(' Run manually: claude-statusline setup\n');
10
+ } else {
11
+ console.log('\n✓ claude-statusline configured. Restart Claude Code to see it.\n');
12
+ }
13
+ } catch (e) {
14
+ // Fully silent on any error — postinstall must never fail npm install
15
+ }
16
+ process.exit(0); // always exit 0 — never fail npm install
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { uninstall } = require('./uninstall');
4
+ try { uninstall(); } catch (e) {} // fully silent — best-effort cleanup
5
+ process.exit(0);
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getSettingsPath, atomicWrite } = require('./config');
5
+
6
+ const UNSAFE_CHARS = /["`$!()\\]/;
7
+
8
+ function setup({ force = false } = {}) {
9
+ // CI guard: skip during local/CI npm installs unless forced (e.g. from CLI)
10
+ if (!force && process.env.npm_config_global !== 'true') {
11
+ return { ok: true, settingsPath: null };
12
+ }
13
+
14
+ const scriptPath = path.resolve(__dirname, '../statusline.js');
15
+ if (!fs.existsSync(scriptPath)) {
16
+ return { ok: false, error: `Could not locate statusline.js at ${scriptPath}` };
17
+ }
18
+
19
+ if (UNSAFE_CHARS.test(process.execPath) || UNSAFE_CHARS.test(scriptPath)) {
20
+ return { ok: false, error: 'Node.js path or install path contains unsupported characters.' };
21
+ }
22
+
23
+ const settingsPath = getSettingsPath();
24
+ let settings = {};
25
+ if (fs.existsSync(settingsPath)) {
26
+ try {
27
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
28
+ } catch (e) {
29
+ return { ok: false, error: 'settings.json contains invalid JSON — fix manually then re-run.' };
30
+ }
31
+ }
32
+
33
+ const command = `"${process.execPath}" "${scriptPath}"`;
34
+ settings.statusLine = { type: 'command', command };
35
+
36
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
37
+
38
+ try {
39
+ atomicWrite(settingsPath, settings);
40
+ } catch (err) {
41
+ return { ok: false, error: err.message };
42
+ }
43
+
44
+ return { ok: true, settingsPath };
45
+ }
46
+
47
+ module.exports = { setup };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const { getSettingsPath, atomicWrite } = require('./config');
4
+
5
+ function uninstall() {
6
+ const settingsPath = getSettingsPath();
7
+ if (!fs.existsSync(settingsPath)) return { ok: true };
8
+
9
+ let settings;
10
+ try {
11
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
12
+ } catch (e) {
13
+ return { ok: false, error: 'settings.json contains invalid JSON — cannot safely modify.' };
14
+ }
15
+
16
+ if (!settings.statusLine) return { ok: true };
17
+ delete settings.statusLine;
18
+
19
+ try {
20
+ atomicWrite(settingsPath, settings);
21
+ } catch (err) {
22
+ return { ok: false, error: err.message };
23
+ }
24
+
25
+ return { ok: true };
26
+ }
27
+
28
+ module.exports = { uninstall };
package/statusline.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code Statusline
3
+ // Shows: model | current task | directory | context usage
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { execSync } = require('child_process');
9
+
10
+ // Read JSON from stdin
11
+ let input = '';
12
+ // Timeout guard: if stdin doesn't close within 3s (e.g. pipe issues on
13
+ // Windows/Git Bash), exit silently instead of hanging.
14
+ const stdinTimeout = setTimeout(() => process.exit(0), 3000);
15
+ process.stdin.setEncoding('utf8');
16
+ process.stdin.on('data', chunk => input += chunk);
17
+ process.stdin.on('end', () => {
18
+ clearTimeout(stdinTimeout);
19
+ try {
20
+ const sanitize = s => String(s).replace(/\x1b\[[0-9;]*[mGKHFABCDJ]/g, '');
21
+ const data = JSON.parse(input);
22
+ const model = sanitize(data.model?.display_name || 'Claude');
23
+ const dir = data.workspace?.current_dir || process.cwd();
24
+ const session = data.session_id || '';
25
+ const remaining = data.context_window?.remaining_percentage;
26
+
27
+ // Context window display (shows USED percentage scaled to usable context)
28
+ // Claude Code reserves ~16.5% for autocompact buffer, so usable context
29
+ // is 83.5% of the total window. We normalize to show 100% at that point.
30
+ const AUTO_COMPACT_BUFFER_PCT = 16.5;
31
+ let ctx = '';
32
+ if (remaining != null) {
33
+ // Normalize: subtract buffer from remaining, scale to usable range
34
+ const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
35
+ const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
36
+
37
+ // Build progress bar (10 segments)
38
+ const filled = Math.floor(used / 10);
39
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
40
+
41
+ // Color based on usable context thresholds
42
+ if (used < 50) {
43
+ ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`;
44
+ } else if (used < 65) {
45
+ ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`;
46
+ } else if (used < 80) {
47
+ ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
48
+ } else {
49
+ ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`;
50
+ }
51
+ }
52
+
53
+ // Current task from todos
54
+ let task = '';
55
+ let activeAgents = 0;
56
+ const homeDir = os.homedir();
57
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude');
58
+ const todosDir = path.join(claudeDir, 'todos');
59
+ if (session && fs.existsSync(todosDir)) {
60
+ try {
61
+ const files = fs.readdirSync(todosDir)
62
+ .filter(f => f.startsWith(session) && f.includes('-agent-') && f.endsWith('.json'))
63
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
64
+ .sort((a, b) => b.mtime - a.mtime);
65
+
66
+ for (const f of files) {
67
+ try {
68
+ const todos = JSON.parse(fs.readFileSync(path.join(todosDir, f.name), 'utf8'));
69
+ const inProgress = todos.find(t => t.status === 'in_progress');
70
+ if (inProgress) {
71
+ activeAgents++;
72
+ if (!task) task = sanitize(inProgress.activeForm || '');
73
+ }
74
+ } catch (e) {}
75
+ }
76
+ } catch (e) {
77
+ // Silently fail on file system errors - don't break statusline
78
+ }
79
+ }
80
+
81
+ // Session cost — only show for API key users; rate_limits presence means subscription
82
+ const isSubscription = data.rate_limits !== undefined;
83
+ const sessionCost = !isSubscription ? (data.cost?.total_cost_usd ?? null) : null;
84
+
85
+ // Usage limits — provided by Claude Code in stdin; no API call needed
86
+ const pct5h = data.rate_limits?.five_hour?.used_percentage ?? null;
87
+ const pctWeek = data.rate_limits?.seven_day?.used_percentage ?? null;
88
+ const resetsAt5h = data.rate_limits?.five_hour?.resets_at ?? null; // Unix epoch seconds
89
+
90
+ function usageLine(label, pct, suffix = '') {
91
+ if (pct === null) return '';
92
+ const p = Math.round(pct);
93
+ const color = p < 50 ? '\x1b[32m' : p < 75 ? '\x1b[33m' : '\x1b[31m';
94
+ return `\x1b[0m\x1b[97m${label}:\x1b[0m ${color}${p}%\x1b[0m${suffix}`;
95
+ }
96
+
97
+ let resetSuffix = '';
98
+ if (resetsAt5h) {
99
+ const resetDate = new Date(resetsAt5h * 1000); // stdin gives epoch seconds
100
+ if (!isNaN(resetDate)) {
101
+ const minsLeft = Math.max(0, Math.round((resetDate - Date.now()) / 60_000));
102
+ const h = Math.floor(minsLeft / 60), m = minsLeft % 60;
103
+ resetSuffix = ` \x1b[2m↺ ${h}h${String(m).padStart(2, '0')}m\x1b[0m`;
104
+ }
105
+ }
106
+
107
+ // Git branch (command is a fixed string; dir is passed as cwd, not interpolated — no injection risk)
108
+ let branch = '';
109
+ try {
110
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
111
+ } catch (e) {}
112
+
113
+ // Output
114
+ const dirname = sanitize(path.basename(dir));
115
+ const safeBranch = sanitize(branch);
116
+ // dirname is bright; branch stays cyan; surrounding prefix/suffix stay dim
117
+ const dirDisplay = safeBranch
118
+ ? `\x1b[1m\x1b[97m${dirname}\x1b[0m\x1b[2m \x1b[36m[${safeBranch}]\x1b[0m`
119
+ : `\x1b[1m\x1b[97m${dirname}\x1b[0m`;
120
+ const u5h = usageLine('Current', pct5h, resetSuffix), u7d = usageLine('Weekly', pctWeek);
121
+ const costDisplay = sessionCost !== null
122
+ ? ` \x1b[33m$${sessionCost < 0.01 ? sessionCost.toFixed(4) : sessionCost.toFixed(2)}\x1b[0m`
123
+ : '';
124
+ const usageContent = [u7d, u5h].filter(Boolean).join(' ');
125
+ const line2 = (usageContent || costDisplay)
126
+ ? `\x1b[0m\x1b[32mUsage\x1b[0m \x1b[2m│\x1b[0m ${[usageContent, costDisplay].filter(Boolean).join(' ')}`
127
+ : '';
128
+ // Effort level: read from env var first, then settings.json, then fall back to
129
+ // model-based default (sonnet-4/opus-4 default to "medium" in Claude Code).
130
+ const effortLetters = { low: 'L', medium: 'M', high: 'H' };
131
+ let effortSuffix = '';
132
+ try {
133
+ let rawEffort = process.env.CLAUDE_CODE_EFFORT_LEVEL
134
+ || JSON.parse(fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf8'))?.effortLevel
135
+ || '';
136
+ if (!rawEffort) {
137
+ const m = model.toLowerCase();
138
+ if (m.includes('sonnet-4') || m.includes('opus-4')) rawEffort = 'medium';
139
+ }
140
+ const effortColors = { low: '\x1b[32m', medium: '\x1b[33m', high: '\x1b[38;5;208m', max: '\x1b[31m' };
141
+ const level = rawEffort?.toLowerCase();
142
+ const color = effortColors[level] || '';
143
+ if (level === 'max') {
144
+ effortSuffix = ` \x1b[0m${color}[MAXX]\x1b[0m`;
145
+ } else {
146
+ const letter = effortLetters[level];
147
+ if (letter) effortSuffix = ` \x1b[0m${color}[${letter}]\x1b[0m`;
148
+ }
149
+ } catch (e) {}
150
+
151
+ const agentDisplay = activeAgents > 0 ? ` \x1b[0m\x1b[36m↪ ${activeAgents}\x1b[0m` : '';
152
+ const modelDisplay = `\x1b[0m\x1b[94m${model}\x1b[0m` + effortSuffix + agentDisplay;
153
+ const line1 = task
154
+ ? `${modelDisplay} \x1b[2m│\x1b[0m \x1b[1m${task}\x1b[0m \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`
155
+ : `${modelDisplay} \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`;
156
+ const visibleLen = line1.replace(/\x1b\[[0-9;]*m/g, '').length;
157
+ const sep = `\x1b[2m${'─'.repeat(visibleLen)}\x1b[0m`;
158
+ process.stdout.write(line2 ? `${line1}\n${sep}\n${line2}` : line1);
159
+ } catch (e) {
160
+ // Silent fail - don't break statusline on parse errors
161
+ }
162
+ });