cc-context-stats 1.5.0 → 1.6.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.
Files changed (45) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
  4. package/.github/workflows/ci.yml +39 -2
  5. package/.github/workflows/release.yml +3 -1
  6. package/CHANGELOG.md +16 -8
  7. package/CLAUDE.md +54 -0
  8. package/CODE_OF_CONDUCT.md +59 -0
  9. package/LICENSE +21 -0
  10. package/README.md +9 -0
  11. package/RELEASE_NOTES.md +16 -6
  12. package/SECURITY.md +44 -0
  13. package/TODOS.md +72 -0
  14. package/assets/logo/favicon.svg +17 -14
  15. package/assets/logo/logo-black.svg +19 -18
  16. package/assets/logo/logo-full.svg +39 -29
  17. package/assets/logo/logo-icon.svg +20 -19
  18. package/assets/logo/logo-mark.svg +21 -19
  19. package/assets/logo/logo-white.svg +19 -18
  20. package/assets/logo/logo-wordmark.svg +5 -6
  21. package/docs/ARCHITECTURE.md +101 -0
  22. package/docs/CSV_FORMAT.md +40 -0
  23. package/docs/DEPLOYMENT.md +60 -0
  24. package/docs/DEVELOPMENT.md +125 -0
  25. package/package.json +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/statusline-full.sh +11 -3
  28. package/scripts/statusline-git.sh +8 -1
  29. package/scripts/statusline-minimal.sh +8 -1
  30. package/scripts/statusline.js +62 -8
  31. package/scripts/statusline.py +24 -10
  32. package/src/claude_statusline/__init__.py +1 -1
  33. package/src/claude_statusline/cli/context_stats.py +20 -1
  34. package/src/claude_statusline/core/config.py +5 -4
  35. package/src/claude_statusline/core/state.py +64 -7
  36. package/src/claude_statusline/formatters/layout.py +17 -2
  37. package/tests/bash/test_parity.bats +315 -0
  38. package/tests/fixtures/json/comma_in_path.json +31 -0
  39. package/tests/node/rotation.test.js +89 -0
  40. package/tests/python/test_data_pipeline.py +446 -0
  41. package/tests/python/test_layout.py +19 -2
  42. package/tests/python/test_state_rotation_validation.py +232 -0
  43. package/tests/python/test_statusline.py +2 -0
  44. package/.claude/commands/context-stats.md +0 -17
  45. package/.claude/settings.local.json +0 -117
@@ -22,12 +22,19 @@ visible_width() {
22
22
  }
23
23
 
24
24
  get_terminal_width() {
25
+ # When running inside Claude Code's statusline subprocess, $COLUMNS is not set
26
+ # and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
27
+ # so no parts are dropped; Claude Code handles overflow.
25
28
  if [[ -n "$COLUMNS" ]]; then
26
29
  echo "$COLUMNS"
27
30
  else
28
31
  local cols
29
32
  cols=$(tput cols 2>/dev/null || echo 80)
30
- echo "$cols"
33
+ if [[ "$cols" -eq 80 ]]; then
34
+ echo 200
35
+ else
36
+ echo "$cols"
37
+ fi
31
38
  fi
32
39
  }
33
40
 
@@ -16,12 +16,19 @@ visible_width() {
16
16
  }
17
17
 
18
18
  get_terminal_width() {
19
+ # When running inside Claude Code's statusline subprocess, $COLUMNS is not set
20
+ # and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
21
+ # so no parts are dropped; Claude Code handles overflow.
19
22
  if [[ -n "$COLUMNS" ]]; then
20
23
  echo "$COLUMNS"
21
24
  else
22
25
  local cols
23
26
  cols=$(tput cols 2>/dev/null || echo 80)
24
- echo "$cols"
27
+ if [[ "$cols" -eq 80 ]]; then
28
+ echo 200
29
+ else
30
+ echo "$cols"
31
+ fi
25
32
  fi
26
33
  }
27
34
 
@@ -28,10 +28,50 @@
28
28
  */
29
29
 
30
30
  const { execSync } = require('child_process');
31
+ const crypto = require('crypto');
31
32
  const path = require('path');
32
33
  const fs = require('fs');
33
34
  const os = require('os');
34
35
 
36
+ const ROTATION_THRESHOLD = 10000;
37
+ const ROTATION_KEEP = 5000;
38
+
39
+ /**
40
+ * Rotate a state file if it exceeds ROTATION_THRESHOLD lines.
41
+ * Keeps the most recent ROTATION_KEEP lines via atomic temp-file + rename.
42
+ */
43
+ function maybeRotateStateFile(stateFile) {
44
+ try {
45
+ if (!fs.existsSync(stateFile)) {
46
+ return;
47
+ }
48
+ const content = fs.readFileSync(stateFile, 'utf8');
49
+ const lines = content.split('\n');
50
+ // Remove trailing empty element from split if file ends with newline
51
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
52
+ lines.pop();
53
+ }
54
+ if (lines.length <= ROTATION_THRESHOLD) {
55
+ return;
56
+ }
57
+ const keep = lines.slice(-ROTATION_KEEP);
58
+ const tmpFile = stateFile + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
59
+ try {
60
+ fs.writeFileSync(tmpFile, keep.join('\n') + '\n');
61
+ fs.renameSync(tmpFile, stateFile);
62
+ } catch (e) {
63
+ try {
64
+ fs.unlinkSync(tmpFile);
65
+ } catch {
66
+ /* cleanup best-effort */
67
+ }
68
+ throw e;
69
+ }
70
+ } catch (e) {
71
+ process.stderr.write(`[statusline] warning: failed to rotate state file: ${e.message}\n`);
72
+ }
73
+ }
74
+
35
75
  // ANSI Colors
36
76
  const BLUE = '\x1b[0;34m';
37
77
  const MAGENTA = '\x1b[0;35m';
@@ -46,6 +86,7 @@ const RESET = '\x1b[0m';
46
86
  * Return the visible width of a string after stripping ANSI escape sequences.
47
87
  */
48
88
  function visibleWidth(s) {
89
+ // eslint-disable-next-line no-control-regex
49
90
  return s.replace(/\x1b\[[0-9;]*m/g, '').length;
50
91
  }
51
92
 
@@ -96,6 +137,7 @@ function getGitInfo(projectDir) {
96
137
  cwd: projectDir,
97
138
  encoding: 'utf8',
98
139
  stdio: ['pipe', 'pipe', 'pipe'],
140
+ timeout: 5000,
99
141
  }).trim();
100
142
 
101
143
  if (!branch) {
@@ -107,6 +149,7 @@ function getGitInfo(projectDir) {
107
149
  cwd: projectDir,
108
150
  encoding: 'utf8',
109
151
  stdio: ['pipe', 'pipe', 'pipe'],
152
+ timeout: 5000,
110
153
  });
111
154
  const changes = status.split('\n').filter(l => l.trim()).length;
112
155
 
@@ -151,8 +194,8 @@ show_delta=true
151
194
  show_session=true
152
195
  `;
153
196
  fs.writeFileSync(configPath, defaultConfig);
154
- } catch {
155
- // Ignore errors creating config
197
+ } catch (e) {
198
+ process.stderr.write(`[statusline] warning: failed to create config: ${e.message}\n`);
156
199
  }
157
200
  return config;
158
201
  }
@@ -181,8 +224,8 @@ show_session=true
181
224
  config.reducedMotion = valueTrimmed !== 'false';
182
225
  }
183
226
  }
184
- } catch {
185
- // Ignore errors
227
+ } catch (e) {
228
+ process.stderr.write(`[statusline] warning: failed to read config: ${e.message}\n`);
186
229
  }
187
230
  return config;
188
231
  }
@@ -338,7 +381,10 @@ process.stdin.on('end', () => {
338
381
  prevTokens = parseInt(lastLine, 10) || 0;
339
382
  }
340
383
  }
341
- } catch {
384
+ } catch (e) {
385
+ process.stderr.write(
386
+ `[statusline] warning: failed to read state file: ${e.message}\n`
387
+ );
342
388
  prevTokens = 0;
343
389
  }
344
390
  // Calculate delta (difference in context window usage)
@@ -372,12 +418,15 @@ process.stdin.on('end', () => {
372
418
  linesRemoved,
373
419
  sessionId || '',
374
420
  modelId,
375
- workspaceProjectDir,
421
+ workspaceProjectDir.replace(/,/g, '_'),
376
422
  totalSize,
377
423
  ].join(',');
378
424
  fs.appendFileSync(stateFile, `${stateData}\n`);
379
- } catch {
380
- // Ignore errors
425
+ maybeRotateStateFile(stateFile);
426
+ } catch (e) {
427
+ process.stderr.write(
428
+ `[statusline] warning: failed to write state file: ${e.message}\n`
429
+ );
381
430
  }
382
431
  }
383
432
  }
@@ -394,3 +443,8 @@ process.stdin.on('end', () => {
394
443
  const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
395
444
  console.log(fitToWidth(parts, maxWidth));
396
445
  });
446
+
447
+ // Export for testing
448
+ if (typeof module !== 'undefined' && module.exports) {
449
+ module.exports = { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP };
450
+ }
@@ -51,8 +51,21 @@ def visible_width(s):
51
51
 
52
52
 
53
53
  def get_terminal_width():
54
- """Return the terminal width in columns, defaulting to 80."""
55
- return shutil.get_terminal_size().columns
54
+ """Return the terminal width in columns.
55
+
56
+ When running inside Claude Code's statusline subprocess, neither $COLUMNS
57
+ nor tput/shutil can detect the real terminal width (they always return 80).
58
+ If COLUMNS is not explicitly set and shutil falls back to 80, we use a
59
+ generous default of 200 so that no parts are unnecessarily dropped;
60
+ Claude Code's own UI handles any overflow/truncation.
61
+ """
62
+ # If COLUMNS is explicitly set, trust it (real terminal or test override)
63
+ if os.environ.get("COLUMNS"):
64
+ return shutil.get_terminal_size().columns
65
+ # No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
66
+ # shutil will fall back to 80, which is too narrow. Use 200 instead.
67
+ cols = shutil.get_terminal_size(fallback=(200, 24)).columns
68
+ return 200 if cols == 80 else cols
56
69
 
57
70
 
58
71
  def fit_to_width(parts, max_width):
@@ -146,8 +159,8 @@ show_delta=true
146
159
  show_session=true
147
160
  """
148
161
  )
149
- except Exception:
150
- pass # Ignore errors creating config
162
+ except Exception as e:
163
+ sys.stderr.write(f"[statusline] warning: failed to create config: {e}\n")
151
164
  return config
152
165
 
153
166
  try:
@@ -169,8 +182,8 @@ show_session=true
169
182
  config["show_session"] = value != "false"
170
183
  elif key == "show_io_tokens":
171
184
  config["show_io_tokens"] = value != "false"
172
- except Exception:
173
- pass
185
+ except (OSError, UnicodeDecodeError) as e:
186
+ sys.stderr.write(f"[statusline] warning: failed to read config: {e}\n")
174
187
  return config
175
188
 
176
189
 
@@ -298,7 +311,8 @@ def main():
298
311
  prev_tokens = int(last_line.split(",")[1])
299
312
  else:
300
313
  prev_tokens = int(last_line or 0)
301
- except Exception:
314
+ except Exception as e:
315
+ sys.stderr.write(f"[statusline] warning: failed to read state file: {e}\n")
302
316
  prev_tokens = 0
303
317
  # Calculate delta
304
318
  delta = used_tokens - prev_tokens
@@ -329,14 +343,14 @@ def main():
329
343
  lines_removed,
330
344
  session_id or "",
331
345
  model_id,
332
- workspace_project_dir,
346
+ workspace_project_dir.replace(",", "_"),
333
347
  total_size,
334
348
  ]
335
349
  )
336
350
  with open(state_file, "a") as f:
337
351
  f.write(f"{state_data}\n")
338
- except Exception:
339
- pass
352
+ except Exception as e:
353
+ sys.stderr.write(f"[statusline] warning: failed to write state file: {e}\n")
340
354
 
341
355
  # Display session_id if enabled
342
356
  if show_session and session_id:
@@ -3,7 +3,7 @@
3
3
  Never run out of context unexpectedly - monitor your session context in real-time.
4
4
  """
5
5
 
6
- __version__ = "1.5.0"
6
+ __version__ = "1.6.0"
7
7
 
8
8
  from claude_statusline.core.config import Config
9
9
  from claude_statusline.core.state import StateFile
@@ -10,6 +10,7 @@ Options:
10
10
  --type <cumulative|delta|io|both|all> Graph type to display (default: both)
11
11
  --watch, -w [interval] Real-time monitoring mode (default: 2s)
12
12
  --no-color Disable color output
13
+ --version, -V Show version and exit
13
14
  --help Show this help
14
15
  """
15
16
 
@@ -24,7 +25,7 @@ from pathlib import Path
24
25
  from claude_statusline import __version__
25
26
  from claude_statusline.core.colors import ColorManager
26
27
  from claude_statusline.core.config import Config
27
- from claude_statusline.core.state import StateFile
28
+ from claude_statusline.core.state import StateFile, _validate_session_id
28
29
  from claude_statusline.graphs.renderer import GraphDimensions, GraphRenderer
29
30
  from claude_statusline.graphs.statistics import calculate_deltas
30
31
  from claude_statusline.ui.icons import get_activity_tier, get_tier_label
@@ -59,6 +60,7 @@ OPTIONS:
59
60
  -w [interval] Set refresh interval in seconds (default: 2)
60
61
  --no-watch Show graphs once and exit (disable live monitoring)
61
62
  --no-color Disable color output
63
+ --version, -V Show version and exit
62
64
  --help Show this help message
63
65
 
64
66
  NOTE:
@@ -131,13 +133,30 @@ def parse_args() -> argparse.Namespace:
131
133
  action="store_true",
132
134
  help="Show help message",
133
135
  )
136
+ parser.add_argument(
137
+ "--version",
138
+ "-V",
139
+ action="store_true",
140
+ help="Show version and exit",
141
+ )
134
142
 
135
143
  args = parser.parse_args()
136
144
 
145
+ if args.version:
146
+ print(f"cc-context-stats {__version__}")
147
+ sys.exit(0)
148
+
137
149
  if args.help:
138
150
  show_help()
139
151
  sys.exit(0)
140
152
 
153
+ if args.session_id is not None:
154
+ try:
155
+ _validate_session_id(args.session_id)
156
+ except ValueError as e:
157
+ sys.stderr.write(f"Error: {e}\n")
158
+ sys.exit(1)
159
+
141
160
  return args
142
161
 
143
162
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
5
6
  from dataclasses import dataclass, field
6
7
  from pathlib import Path
7
8
  from typing import Any
@@ -63,8 +64,8 @@ show_session=true
63
64
  reduced_motion=false
64
65
  """
65
66
  )
66
- except OSError:
67
- pass # Ignore errors creating config
67
+ except OSError as e:
68
+ sys.stderr.write(f"[statusline] warning: failed to create config {self._config_path}: {e}\n")
68
69
 
69
70
  def _read_config(self) -> None:
70
71
  """Read settings from config file."""
@@ -90,8 +91,8 @@ reduced_motion=false
90
91
  self.show_io_tokens = value != "false"
91
92
  elif key == "reduced_motion":
92
93
  self.reduced_motion = value != "false"
93
- except OSError:
94
- pass # Use defaults on read error
94
+ except (OSError, UnicodeDecodeError) as e:
95
+ sys.stderr.write(f"[statusline] warning: failed to read config {self._config_path}: {e}\n")
95
96
 
96
97
  def to_dict(self) -> dict[str, Any]:
97
98
  """Convert config to dictionary."""
@@ -2,7 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import shutil
7
+ import sys
8
+ import tempfile
6
9
  from dataclasses import dataclass
7
10
  from pathlib import Path
8
11
 
@@ -115,7 +118,7 @@ class StateEntry:
115
118
  self.lines_removed,
116
119
  self.session_id,
117
120
  self.model_id,
118
- self.workspace_project_dir,
121
+ self.workspace_project_dir.replace(",", "_"),
119
122
  self.context_window_size,
120
123
  ]
121
124
  )
@@ -131,11 +134,30 @@ class StateEntry:
131
134
  return self.current_input_tokens + self.cache_creation + self.cache_read
132
135
 
133
136
 
137
+ def _validate_session_id(session_id: str) -> None:
138
+ """Validate that a session ID does not contain dangerous path characters.
139
+
140
+ Args:
141
+ session_id: Session ID to validate
142
+
143
+ Raises:
144
+ ValueError: If session_id contains '/', '\\', '..', or null bytes
145
+ """
146
+ for bad in ("/", "\\", "..", "\0"):
147
+ if bad in session_id:
148
+ raise ValueError(
149
+ f"Invalid session_id: contains '{bad}'. "
150
+ "Session IDs must not contain '/', '\\', '..', or null bytes."
151
+ )
152
+
153
+
134
154
  class StateFile:
135
155
  """Manage state files for token tracking."""
136
156
 
137
157
  STATE_DIR = Path.home() / ".claude" / "statusline"
138
158
  OLD_STATE_DIR = Path.home() / ".claude"
159
+ ROTATION_THRESHOLD = 10_000
160
+ ROTATION_KEEP = 5_000
139
161
 
140
162
  def __init__(self, session_id: str | None = None) -> None:
141
163
  """Initialize state file manager.
@@ -143,6 +165,8 @@ class StateFile:
143
165
  Args:
144
166
  session_id: Optional session ID. If not provided, uses latest session.
145
167
  """
168
+ if session_id is not None:
169
+ _validate_session_id(session_id)
146
170
  self.session_id = session_id
147
171
  self._ensure_state_dir()
148
172
  self._migrate_old_files()
@@ -211,8 +235,8 @@ class StateFile:
211
235
  entry = StateEntry.from_csv_line(line)
212
236
  if entry:
213
237
  entries.append(entry)
214
- except OSError:
215
- pass
238
+ except OSError as e:
239
+ sys.stderr.write(f"[statusline] warning: failed to read state history {file_path}: {e}\n")
216
240
 
217
241
  return entries
218
242
 
@@ -233,8 +257,8 @@ class StateFile:
233
257
  for line in reversed(lines):
234
258
  if line.strip():
235
259
  return StateEntry.from_csv_line(line)
236
- except OSError:
237
- pass
260
+ except OSError as e:
261
+ sys.stderr.write(f"[statusline] warning: failed to read last entry {file_path}: {e}\n")
238
262
 
239
263
  return None
240
264
 
@@ -247,8 +271,41 @@ class StateFile:
247
271
  try:
248
272
  with open(self.file_path, "a") as f:
249
273
  f.write(f"{entry.to_csv_line()}\n")
250
- except OSError:
251
- pass
274
+ except OSError as e:
275
+ sys.stderr.write(f"[statusline] warning: failed to write state {self.file_path}: {e}\n")
276
+ return
277
+ self._maybe_rotate()
278
+
279
+ def _maybe_rotate(self) -> None:
280
+ """Rotate state file if it exceeds the line threshold.
281
+
282
+ If the file has more than ROTATION_THRESHOLD lines, truncate to
283
+ the most recent ROTATION_KEEP lines via atomic temp-file + rename.
284
+ """
285
+ file_path = self.file_path
286
+ try:
287
+ if not file_path.exists():
288
+ return
289
+ lines = file_path.read_text().splitlines(keepends=True)
290
+ if len(lines) <= self.ROTATION_THRESHOLD:
291
+ return
292
+ keep = lines[-self.ROTATION_KEEP :]
293
+ fd = tempfile.NamedTemporaryFile(
294
+ dir=str(self.STATE_DIR), delete=False, mode="w", suffix=".tmp"
295
+ )
296
+ try:
297
+ fd.writelines(keep)
298
+ fd.close()
299
+ os.replace(fd.name, str(file_path))
300
+ except BaseException:
301
+ fd.close()
302
+ try:
303
+ os.unlink(fd.name)
304
+ except OSError:
305
+ pass
306
+ raise
307
+ except OSError as e:
308
+ sys.stderr.write(f"[statusline] warning: failed to rotate state file {file_path}: {e}\n")
252
309
 
253
310
  def list_sessions(self) -> list[str]:
254
311
  """List all available session IDs.
@@ -15,8 +15,23 @@ def visible_width(s: str) -> int:
15
15
 
16
16
 
17
17
  def get_terminal_width() -> int:
18
- """Return the terminal width in columns, defaulting to 80."""
19
- return shutil.get_terminal_size().columns
18
+ """Return the terminal width in columns.
19
+
20
+ When running inside Claude Code's statusline subprocess, neither $COLUMNS
21
+ nor tput/shutil can detect the real terminal width (they always return 80).
22
+ If COLUMNS is not explicitly set and shutil falls back to 80, we use a
23
+ generous default of 200 so that no parts are unnecessarily dropped;
24
+ Claude Code's own UI handles any overflow/truncation.
25
+ """
26
+ import os
27
+
28
+ # If COLUMNS is explicitly set, trust it (real terminal or test override)
29
+ if os.environ.get("COLUMNS"):
30
+ return shutil.get_terminal_size().columns
31
+ # No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
32
+ # shutil will fall back to 80, which is too narrow. Use 200 instead.
33
+ cols = shutil.get_terminal_size(fallback=(200, 24)).columns
34
+ return 200 if cols == 80 else cols
20
35
 
21
36
 
22
37
  def fit_to_width(parts: list[str], max_width: int) -> str: