cc-context-stats 1.5.1 → 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.
@@ -0,0 +1,60 @@
1
+ # Deployment
2
+
3
+ ## Distribution Channels
4
+
5
+ cc-context-stats is distributed through three channels:
6
+
7
+ | Channel | Package Name | Command |
8
+ | ------------ | ----------------- | ------------------------------------ |
9
+ | Shell script | N/A | `curl -fsSL .../install.sh \| bash` |
10
+ | PyPI | `cc-context-stats`| `pip install cc-context-stats` |
11
+ | npm | `cc-context-stats`| `npm install -g cc-context-stats` |
12
+
13
+ ## Publishing to PyPI
14
+
15
+ ```bash
16
+ # Ensure clean build
17
+ rm -rf dist/ build/
18
+
19
+ # Build
20
+ python -m build
21
+
22
+ # Check package
23
+ twine check dist/*
24
+
25
+ # Upload to PyPI
26
+ twine upload dist/*
27
+ ```
28
+
29
+ ## Publishing to npm
30
+
31
+ ```bash
32
+ # Verify package.json
33
+ npm pack --dry-run
34
+
35
+ # Publish
36
+ npm publish
37
+ ```
38
+
39
+ ## Release Workflow
40
+
41
+ The project uses GitHub Actions for automated releases (`.github/workflows/release.yml`):
42
+
43
+ 1. Create and push a version tag: `git tag v1.x.x && git push --tags`
44
+ 2. The release workflow automatically:
45
+ - Runs the full test suite
46
+ - Builds Python and npm packages
47
+ - Creates a GitHub Release with release notes
48
+
49
+ ## Version Management
50
+
51
+ Versions must be updated in sync across:
52
+
53
+ - `pyproject.toml` - `[project] version`
54
+ - `package.json` - `version`
55
+ - `CHANGELOG.md` - New version entry
56
+ - `RELEASE_NOTES.md` - Current release notes
57
+
58
+ ## Install Script
59
+
60
+ The `install.sh` script is fetched directly from the `main` branch on GitHub. Changes to the installer take effect immediately for new users running the curl one-liner.
@@ -0,0 +1,125 @@
1
+ # Development Guide
2
+
3
+ ## Prerequisites
4
+
5
+ - **Git** - Version control
6
+ - **jq** - JSON processor (for bash scripts)
7
+ - **Python 3.9+** - For Python package and testing
8
+ - **Node.js 18+** - For Node.js script and testing
9
+ - **Bats** - Bash Automated Testing System (optional, for bash tests)
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ # Clone the repository
15
+ git clone https://github.com/luongnv89/cc-context-stats.git
16
+ cd cc-context-stats
17
+
18
+ # Python setup
19
+ python3 -m venv venv
20
+ source venv/bin/activate
21
+ pip install -r requirements-dev.txt
22
+ pip install -e ".[dev]"
23
+
24
+ # Node.js setup
25
+ npm install
26
+
27
+ # Install pre-commit hooks
28
+ pre-commit install
29
+ ```
30
+
31
+ ## Project Layout
32
+
33
+ ```
34
+ cc-context-stats/
35
+ ├── src/claude_statusline/ # Python package source
36
+ ├── scripts/ # Standalone scripts (sh/py/js)
37
+ ├── tests/
38
+ │ ├── bash/ # Bats tests
39
+ │ ├── python/ # Pytest tests
40
+ │ └── node/ # Jest tests
41
+ ├── config/ # Configuration examples
42
+ ├── docs/ # Documentation
43
+ ├── .github/workflows/ # CI/CD
44
+ ├── pyproject.toml # Python build config
45
+ └── package.json # Node.js config
46
+ ```
47
+
48
+ ## Running Tests
49
+
50
+ ```bash
51
+ # All tests
52
+ npm test && pytest && bats tests/bash/*.bats
53
+
54
+ # Individual suites
55
+ pytest tests/python/ -v # Python
56
+ pytest tests/python/ -v --cov=scripts --cov-report=html # Python + coverage
57
+ npm test # Node.js (Jest)
58
+ npm run test:coverage # Node.js + coverage
59
+ bats tests/bash/*.bats # Bash
60
+ ```
61
+
62
+ ## Linting & Formatting
63
+
64
+ ```bash
65
+ # Run all checks via pre-commit
66
+ pre-commit run --all-files
67
+
68
+ # Individual tools
69
+ ruff check src/ scripts/statusline.py # Python lint
70
+ ruff format src/ scripts/statusline.py # Python format
71
+ npx eslint scripts/statusline.js # JavaScript lint
72
+ npx prettier --write scripts/statusline.js # JavaScript format
73
+ shellcheck scripts/*.sh install.sh # Bash lint
74
+ ```
75
+
76
+ ## Manual Testing
77
+
78
+ ```bash
79
+ # Test statusline scripts with mock input
80
+ echo '{"model":{"display_name":"Test"},"cwd":"/test","session_id":"abc123","context":{"tokens_remaining":64000,"context_window":200000}}' | python3 scripts/statusline.py
81
+
82
+ echo '{"model":{"display_name":"Test"}}' | node scripts/statusline.js
83
+
84
+ echo '{"model":{"display_name":"Test"}}' | bash scripts/statusline-full.sh
85
+ ```
86
+
87
+ ## Building
88
+
89
+ ```bash
90
+ # Python package
91
+ python -m build
92
+
93
+ # Verify package
94
+ twine check dist/*
95
+ ```
96
+
97
+ ## Cross-Script Consistency
98
+
99
+ All three implementations (bash, Python, Node.js) must produce identical output for the same input. When modifying status line behavior:
100
+
101
+ 1. Update all three script variants
102
+ 2. Run integration tests to verify parity
103
+ 3. Test on multiple platforms if possible
104
+
105
+ ## Debugging
106
+
107
+ ### State files
108
+
109
+ ```bash
110
+ # View current state files
111
+ ls -la ~/.claude/statusline/statusline.*.state
112
+
113
+ # Inspect state content
114
+ cat ~/.claude/statusline/statusline.<session_id>.state
115
+ ```
116
+
117
+ ### Verbose testing
118
+
119
+ ```bash
120
+ # Python with verbose output
121
+ pytest tests/python/ -v -s
122
+
123
+ # Node.js with verbose output
124
+ npx jest --verbose
125
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-context-stats",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Monitor your Claude Code session context in real-time - track token usage and never run out of context",
5
5
  "main": "scripts/statusline.js",
6
6
  "scripts": {
@@ -32,7 +32,7 @@
32
32
  "devDependencies": {
33
33
  "eslint": "^8.56.0",
34
34
  "jest": "^29.7.0",
35
- "prettier": "^3.2.0"
35
+ "prettier": "^3.8.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": ">=18"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cc-context-stats"
7
- version = "1.5.1"
7
+ version = "1.6.0"
8
8
  description = "Monitor your Claude Code session context in real-time - track token usage and never run out of context"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -178,7 +178,7 @@ cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // 0')
178
178
  lines_added=$(echo "$input" | jq -r '.cost.total_lines_added // 0')
179
179
  lines_removed=$(echo "$input" | jq -r '.cost.total_lines_removed // 0')
180
180
  model_id=$(echo "$input" | jq -r '.model.id // ""')
181
- workspace_project_dir=$(echo "$input" | jq -r '.workspace.project_dir // ""')
181
+ workspace_project_dir=$(echo "$input" | jq -r '.workspace.project_dir // ""' | tr ',' '_')
182
182
 
183
183
  if [[ "$total_size" -gt 0 && "$current_usage" != "null" ]]; then
184
184
  # Get tokens from current_usage (includes cache)
@@ -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';
@@ -97,6 +137,7 @@ function getGitInfo(projectDir) {
97
137
  cwd: projectDir,
98
138
  encoding: 'utf8',
99
139
  stdio: ['pipe', 'pipe', 'pipe'],
140
+ timeout: 5000,
100
141
  }).trim();
101
142
 
102
143
  if (!branch) {
@@ -108,6 +149,7 @@ function getGitInfo(projectDir) {
108
149
  cwd: projectDir,
109
150
  encoding: 'utf8',
110
151
  stdio: ['pipe', 'pipe', 'pipe'],
152
+ timeout: 5000,
111
153
  });
112
154
  const changes = status.split('\n').filter(l => l.trim()).length;
113
155
 
@@ -152,8 +194,8 @@ show_delta=true
152
194
  show_session=true
153
195
  `;
154
196
  fs.writeFileSync(configPath, defaultConfig);
155
- } catch {
156
- // Ignore errors creating config
197
+ } catch (e) {
198
+ process.stderr.write(`[statusline] warning: failed to create config: ${e.message}\n`);
157
199
  }
158
200
  return config;
159
201
  }
@@ -182,8 +224,8 @@ show_session=true
182
224
  config.reducedMotion = valueTrimmed !== 'false';
183
225
  }
184
226
  }
185
- } catch {
186
- // Ignore errors
227
+ } catch (e) {
228
+ process.stderr.write(`[statusline] warning: failed to read config: ${e.message}\n`);
187
229
  }
188
230
  return config;
189
231
  }
@@ -339,7 +381,10 @@ process.stdin.on('end', () => {
339
381
  prevTokens = parseInt(lastLine, 10) || 0;
340
382
  }
341
383
  }
342
- } catch {
384
+ } catch (e) {
385
+ process.stderr.write(
386
+ `[statusline] warning: failed to read state file: ${e.message}\n`
387
+ );
343
388
  prevTokens = 0;
344
389
  }
345
390
  // Calculate delta (difference in context window usage)
@@ -373,12 +418,15 @@ process.stdin.on('end', () => {
373
418
  linesRemoved,
374
419
  sessionId || '',
375
420
  modelId,
376
- workspaceProjectDir,
421
+ workspaceProjectDir.replace(/,/g, '_'),
377
422
  totalSize,
378
423
  ].join(',');
379
424
  fs.appendFileSync(stateFile, `${stateData}\n`);
380
- } catch {
381
- // Ignore errors
425
+ maybeRotateStateFile(stateFile);
426
+ } catch (e) {
427
+ process.stderr.write(
428
+ `[statusline] warning: failed to write state file: ${e.message}\n`
429
+ );
382
430
  }
383
431
  }
384
432
  }
@@ -395,3 +443,8 @@ process.stdin.on('end', () => {
395
443
  const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
396
444
  console.log(fitToWidth(parts, maxWidth));
397
445
  });
446
+
447
+ // Export for testing
448
+ if (typeof module !== 'undefined' && module.exports) {
449
+ module.exports = { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP };
450
+ }
@@ -159,8 +159,8 @@ show_delta=true
159
159
  show_session=true
160
160
  """
161
161
  )
162
- except Exception:
163
- pass # Ignore errors creating config
162
+ except Exception as e:
163
+ sys.stderr.write(f"[statusline] warning: failed to create config: {e}\n")
164
164
  return config
165
165
 
166
166
  try:
@@ -182,8 +182,8 @@ show_session=true
182
182
  config["show_session"] = value != "false"
183
183
  elif key == "show_io_tokens":
184
184
  config["show_io_tokens"] = value != "false"
185
- except Exception:
186
- pass
185
+ except (OSError, UnicodeDecodeError) as e:
186
+ sys.stderr.write(f"[statusline] warning: failed to read config: {e}\n")
187
187
  return config
188
188
 
189
189
 
@@ -311,7 +311,8 @@ def main():
311
311
  prev_tokens = int(last_line.split(",")[1])
312
312
  else:
313
313
  prev_tokens = int(last_line or 0)
314
- except Exception:
314
+ except Exception as e:
315
+ sys.stderr.write(f"[statusline] warning: failed to read state file: {e}\n")
315
316
  prev_tokens = 0
316
317
  # Calculate delta
317
318
  delta = used_tokens - prev_tokens
@@ -342,14 +343,14 @@ def main():
342
343
  lines_removed,
343
344
  session_id or "",
344
345
  model_id,
345
- workspace_project_dir,
346
+ workspace_project_dir.replace(",", "_"),
346
347
  total_size,
347
348
  ]
348
349
  )
349
350
  with open(state_file, "a") as f:
350
351
  f.write(f"{state_data}\n")
351
- except Exception:
352
- pass
352
+ except Exception as e:
353
+ sys.stderr.write(f"[statusline] warning: failed to write state file: {e}\n")
353
354
 
354
355
  # Display session_id if enabled
355
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.1"
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.