claude-simple-status 1.3.2 → 1.4.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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +2 -1
  3. package/statusline.mjs +60 -15
package/README.md CHANGED
@@ -21,8 +21,8 @@ A simple, no-frills statusline for [Claude Code](https://docs.anthropic.com/en/d
21
21
  - **Cross-platform** — works on macOS, Linux, and Windows
22
22
  - **Non-blocking** — returns cached data instantly, refreshes quota in the background
23
23
  - **Color-coded** — green/orange/red percentages at a glance
24
- - **Context velocity** — estimates remaining turns until context compaction (`42% →~8t`), with directional arrows showing if burn rate is accelerating (↑), steady (→), or decelerating (↓)
25
- - **Quota pressure** — reset time changes color based on projected burn rate: green (safe), orange (cutting it close), red (will hit the limit before reset). The 7d percentage color is also overridden when the projection says danger
24
+ - **Context velocity** *(opt-in)* — estimates remaining turns until context compaction (`42% →~8t`), with directional arrows showing if burn rate is accelerating (↑), steady (→), or decelerating (↓)
25
+ - **Quota pressure** *(opt-in)* — reset time changes color based on projected burn rate: green (safe), orange (cutting it close), red (will hit the limit before reset). The 7d percentage color is also overridden when the projection says danger
26
26
  - **Project name** — bold uppercase project directory name so you never mix up sessions
27
27
  - **Git-aware** — shows the current branch name in repos (cached 30s to reduce overhead)
28
28
  - **API cost tracking** — pay-as-you-go API users see cumulative session cost instead of quota
@@ -92,6 +92,26 @@ To uninstall, remove `~/.claude/statusline/` and the `"statusLine"` block from s
92
92
 
93
93
  </details>
94
94
 
95
+ ## Configuration
96
+
97
+ Create `~/.config/claude-simple-status.json` to enable optional features:
98
+
99
+ ```json
100
+ {
101
+ "contextVelocity": true,
102
+ "quotaBurnRate": true,
103
+ "refreshInterval": 300
104
+ }
105
+ ```
106
+
107
+ | Option | Default | Description |
108
+ |--------|---------|-------------|
109
+ | `contextVelocity` | `false` | Show estimated turns remaining until context compaction (`42% →~8t`) |
110
+ | `quotaBurnRate` | `false` | Color reset time and 7d quota based on projected burn rate |
111
+ | `refreshInterval` | `300` | Seconds between quota API refreshes (min 60) |
112
+
113
+ Without this file, you get a clean statusline showing just project, branch, model, context %, reset time, and quota percentages.
114
+
95
115
  ## Requirements
96
116
 
97
117
  - Claude Code CLI
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "claude-simple-status",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "A simple statusline for Claude Code — project name, git branch, model, context usage, quota, and API costs at a glance",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-simple-status": "statusline.mjs"
8
8
  },
9
9
  "scripts": {
10
+ "test": "node --test statusline.test.mjs",
10
11
  "postinstall": "node scripts/setup.mjs install",
11
12
  "preuninstall": "node scripts/setup.mjs uninstall"
12
13
  },
package/statusline.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // Claude Code Statusline - Shows Branch | Model | Context % | Next Reset | 5h Quota % | 7d Quota %
3
3
  // Cross-platform Node.js version (no dependencies)
4
4
 
5
- import { readFileSync, writeFileSync, mkdirSync, rmdirSync, statSync, existsSync } from 'fs';
5
+ import { readFileSync, writeFileSync, mkdirSync, rmdirSync, statSync, existsSync, realpathSync, readdirSync, unlinkSync } from 'fs';
6
6
  import { homedir, tmpdir } from 'os';
7
7
  import { join, basename } from 'path';
8
8
  import { spawn, execSync } from 'child_process';
9
+ import { fileURLToPath } from 'url';
9
10
 
10
11
  // Handle --uninstall flag (workaround: npm doesn't run preuninstall for global packages)
11
12
  if (process.argv.includes('--uninstall')) {
@@ -40,9 +41,9 @@ const CREDS_FILE = join(homedir(), '.claude', '.credentials.json');
40
41
  const CACHE_FILE = join(tmpdir(), 'claude-statusline-quota.json');
41
42
  const LOCK_DIR = join(tmpdir(), 'claude-statusline-quota.lock');
42
43
  const ERROR_FILE = join(tmpdir(), 'claude-statusline-error');
44
+ const BACKOFF_FILE = join(tmpdir(), 'claude-statusline-backoff');
43
45
  const LOG_FILE = join(tmpdir(), 'claude-statusline.log');
44
- const CACHE_MAX_AGE = 120; // seconds - when to fetch
45
- const CACHE_STALE_AGE = 300; // seconds - when to show "--" instead of old values
46
+ let CACHE_STALE_AGE = 300;
46
47
  const GIT_BRANCH_CACHE = join(tmpdir(), 'claude-statusline-branches.json');
47
48
  const GIT_BRANCH_MAX_AGE = 30; // seconds
48
49
  const CONTEXT_HISTORY_FILE = join(tmpdir(), 'claude-statusline-context.json');
@@ -81,6 +82,14 @@ function readJsonFile(filepath) {
81
82
  }
82
83
  }
83
84
 
85
+ // User config — features off by default, opt-in via ~/.config/claude-simple-status.json
86
+ const CONFIG_FILE = join(homedir(), '.config', 'claude-simple-status.json');
87
+ const userConfig = readJsonFile(CONFIG_FILE) || {};
88
+ const SHOW_CONTEXT_VELOCITY = userConfig.contextVelocity === true;
89
+ const SHOW_BURN_RATE = userConfig.quotaBurnRate === true;
90
+ const CACHE_MAX_AGE = Math.max(60, Number(userConfig.refreshInterval) || 300);
91
+ CACHE_STALE_AGE = Math.max(CACHE_STALE_AGE, CACHE_MAX_AGE * 2);
92
+
84
93
  // Clean up stale lock (older than 30s)
85
94
  function cleanStaleLock() {
86
95
  if (existsSync(LOCK_DIR) && getFileAge(LOCK_DIR) > 30) {
@@ -108,6 +117,7 @@ function refreshInBackground(token) {
108
117
  const CACHE_FILE = ${JSON.stringify(CACHE_FILE)};
109
118
  const LOCK_DIR = ${JSON.stringify(LOCK_DIR)};
110
119
  const ERROR_FILE = ${JSON.stringify(ERROR_FILE)};
120
+ const BACKOFF_FILE = ${JSON.stringify(BACKOFF_FILE)};
111
121
  const LOG_FILE = ${JSON.stringify(LOG_FILE)};
112
122
 
113
123
  function logError(msg) {
@@ -125,10 +135,10 @@ function refreshInBackground(token) {
125
135
  path: '/api/oauth/usage',
126
136
  method: 'GET',
127
137
  headers: {
128
- 'Authorization': 'Bearer ${token}',
138
+ 'Authorization': 'Bearer ' + process.env._CLAUDE_SS_TOKEN,
129
139
  'anthropic-beta': 'oauth-2025-04-20',
130
140
  'Accept': 'application/json',
131
- 'User-Agent': 'claude-code/2.1.12'
141
+ 'User-Agent': 'claude-simple-status/1.5.0'
132
142
  },
133
143
  timeout: 10000
134
144
  }, (res) => {
@@ -140,9 +150,14 @@ function refreshInBackground(token) {
140
150
  JSON.parse(data);
141
151
  writeFileSync(CACHE_FILE, data);
142
152
  try { writeFileSync(ERROR_FILE, ''); } catch {}
153
+ try { writeFileSync(BACKOFF_FILE, '{}'); } catch {}
143
154
  } catch { logError('Invalid JSON'); }
155
+ } else if (res.statusCode === 429) {
156
+ const retrySec = parseInt(res.headers['retry-after'], 10) || 300;
157
+ const until = Date.now() + retrySec * 1000;
158
+ try { writeFileSync(BACKOFF_FILE, JSON.stringify({ until })); } catch {}
159
+ logError('HTTP 429 (backoff ' + retrySec + 's)');
144
160
  } else if (res.statusCode !== 401) {
145
- // Skip 401 - token not ready yet at startup, will retry next cycle
146
161
  logError('HTTP ' + res.statusCode);
147
162
  }
148
163
  try { rmdirSync(LOCK_DIR); } catch {}
@@ -154,7 +169,8 @@ function refreshInBackground(token) {
154
169
  `
155
170
  ], {
156
171
  detached: true,
157
- stdio: 'ignore'
172
+ stdio: 'ignore',
173
+ env: { _CLAUDE_SS_TOKEN: token }
158
174
  });
159
175
  child.unref();
160
176
  }
@@ -345,6 +361,25 @@ async function main() {
345
361
  }
346
362
  } catch {}
347
363
 
364
+ // Persist session snapshot for external tools (/tmp/claude-sessions/<id>.json)
365
+ try {
366
+ const sessionId = JSON.parse(input).session_id;
367
+ if (sessionId) {
368
+ const sessDir = join(tmpdir(), 'claude-sessions');
369
+ mkdirSync(sessDir, { recursive: true });
370
+ writeFileSync(join(sessDir, `${sessionId}.json`), input);
371
+ // Clean up stale sessions (not updated in 5 min = dead)
372
+ for (const f of readdirSync(sessDir)) {
373
+ if (f.endsWith('.json')) {
374
+ try {
375
+ const age = Date.now() - statSync(join(sessDir, f)).mtimeMs;
376
+ if (age > 5 * 60 * 1000) unlinkSync(join(sessDir, f));
377
+ } catch {}
378
+ }
379
+ }
380
+ }
381
+ } catch {}
382
+
348
383
  // Get OAuth token
349
384
  let token = null;
350
385
  const creds = readJsonFile(CREDS_FILE);
@@ -360,8 +395,10 @@ async function main() {
360
395
  cleanStaleLock();
361
396
  const cacheAge = getFileAge(CACHE_FILE);
362
397
  const needRefresh = !quotaData || cacheAge >= CACHE_MAX_AGE;
398
+ const backoffUntil = Number(readJsonFile(BACKOFF_FILE)?.until || 0);
399
+ const inBackoff = Date.now() < backoffUntil;
363
400
 
364
- if (needRefresh && acquireLock()) {
401
+ if (needRefresh && !inBackoff && acquireLock()) {
365
402
  refreshInBackground(token);
366
403
  }
367
404
  }
@@ -400,8 +437,8 @@ async function main() {
400
437
  hasError = errContent.length > 0;
401
438
  } catch {}
402
439
 
403
- // Get context velocity estimate
404
- const velocity = getContextVelocity(projectDir, contextUsed);
440
+ // Get context velocity estimate (opt-in)
441
+ const velocity = SHOW_CONTEXT_VELOCITY ? getContextVelocity(projectDir, contextUsed) : null;
405
442
 
406
443
  // Get rig name (claude-rig sets CLAUDE_CONFIG_DIR to ~/.claude-rig/rigs/<name>)
407
444
  const rigProfile = (() => {
@@ -426,15 +463,15 @@ async function main() {
426
463
  contextDisplay += ` ${turnsColor}${velocity.arrow}${turnsStr}${RESET}`;
427
464
  }
428
465
 
429
- // Color the reset time based on 5h quota burn rate projection
430
- const fiveHourPressure = getQuotaPressure('5h', fiveHourPct, fiveHourResetsAt);
466
+ // Color the reset time based on 5h quota burn rate projection (opt-in)
467
+ const fiveHourPressure = SHOW_BURN_RATE ? getQuotaPressure('5h', fiveHourPct, fiveHourResetsAt) : null;
431
468
  let resetDisplay = resetLocal;
432
469
  if (fiveHourPressure === 'danger') resetDisplay = `${RED}${resetLocal}${RESET}`;
433
470
  else if (fiveHourPressure === 'tight') resetDisplay = `${ORANGE}${resetLocal}${RESET}`;
434
471
  else if (fiveHourPressure === 'safe') resetDisplay = `${GREEN}${resetLocal}${RESET}`;
435
472
 
436
- // Override 7d percentage color when burn rate projects exhaustion before reset
437
- const sevenDayPressure = getQuotaPressure('7d', sevenDayPct, sevenDayResetsAt);
473
+ // Override 7d percentage color when burn rate projects exhaustion before reset (opt-in)
474
+ const sevenDayPressure = SHOW_BURN_RATE ? getQuotaPressure('7d', sevenDayPct, sevenDayResetsAt) : null;
438
475
  let sevenDayDisplay = colorPct(sevenDayPct);
439
476
  if (sevenDayPressure === 'danger') sevenDayDisplay = `${RED}${sevenDayPct}%${RESET}`;
440
477
  else if (sevenDayPressure === 'tight') sevenDayDisplay = `${ORANGE}${sevenDayPct}%${RESET}`;
@@ -452,4 +489,12 @@ async function main() {
452
489
  process.stdout.write(output);
453
490
  }
454
491
 
455
- main().catch(() => process.exit(1));
492
+ // Only run when executed directly (not imported for testing)
493
+ const __filename = fileURLToPath(import.meta.url);
494
+ const _isMain = (() => {
495
+ try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(__filename); }
496
+ catch { return false; }
497
+ })();
498
+ if (_isMain) main().catch(() => process.exit(1));
499
+
500
+ export { colorPct, getFileAge, readJsonFile, toLocalTime, getContextVelocity, getQuotaPressure, main };