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
@@ -0,0 +1,232 @@
1
+ """Tests for state file rotation and session ID validation."""
2
+
3
+ import re
4
+ import subprocess
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from claude_statusline.core.state import StateFile, _validate_session_id
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ def _make_csv_line(index: int) -> str:
18
+ """Generate a deterministic CSV state line for a given index."""
19
+ return (
20
+ f"{1710288000 + index},100,200,300,400,500,600,0.01,"
21
+ f"10,5,sess-{index},model,/tmp/proj,200000"
22
+ )
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # State File Rotation
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class TestStateFileRotation:
31
+ """Tests for _maybe_rotate() in StateFile."""
32
+
33
+ def test_below_threshold_no_rotation(self, tmp_path, monkeypatch):
34
+ """File with fewer than 10,000 lines is not rotated."""
35
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
36
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
37
+ (tmp_path / "old").mkdir()
38
+
39
+ sf = StateFile("test-session")
40
+ # Write 9,999 lines
41
+ lines = [_make_csv_line(i) + "\n" for i in range(9_999)]
42
+ sf.file_path.write_text("".join(lines))
43
+
44
+ sf._maybe_rotate()
45
+
46
+ result_lines = sf.file_path.read_text().splitlines()
47
+ assert len(result_lines) == 9_999
48
+
49
+ def test_at_threshold_no_rotation(self, tmp_path, monkeypatch):
50
+ """File with exactly 10,000 lines is not rotated."""
51
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
52
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
53
+ (tmp_path / "old").mkdir()
54
+
55
+ sf = StateFile("test-session")
56
+ lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
57
+ sf.file_path.write_text("".join(lines))
58
+
59
+ sf._maybe_rotate()
60
+
61
+ result_lines = sf.file_path.read_text().splitlines()
62
+ assert len(result_lines) == 10_000
63
+
64
+ def test_exceeds_threshold_truncates_to_5000(self, tmp_path, monkeypatch):
65
+ """File with 10,001 lines is truncated to 5,000."""
66
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
67
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
68
+ (tmp_path / "old").mkdir()
69
+
70
+ sf = StateFile("test-session")
71
+ lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
72
+ sf.file_path.write_text("".join(lines))
73
+
74
+ sf._maybe_rotate()
75
+
76
+ result_lines = sf.file_path.read_text().splitlines()
77
+ assert len(result_lines) == 5_000
78
+
79
+ def test_retained_lines_are_most_recent(self, tmp_path, monkeypatch):
80
+ """After rotation, the retained lines are the last 5,000 of the original."""
81
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
82
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
83
+ (tmp_path / "old").mkdir()
84
+
85
+ sf = StateFile("test-session")
86
+ total = 10_001
87
+ lines = [_make_csv_line(i) + "\n" for i in range(total)]
88
+ sf.file_path.write_text("".join(lines))
89
+
90
+ sf._maybe_rotate()
91
+
92
+ result_lines = sf.file_path.read_text().splitlines()
93
+ # First retained line should be the one at index (total - 5000) = 5001
94
+ assert f"sess-{total - 5000}" in result_lines[0]
95
+ # Last retained line should be the last original line
96
+ assert f"sess-{total - 1}" in result_lines[-1]
97
+
98
+ def test_rotation_via_append_entry(self, tmp_path, monkeypatch):
99
+ """append_entry triggers rotation when threshold is exceeded."""
100
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
101
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
102
+ (tmp_path / "old").mkdir()
103
+
104
+ sf = StateFile("test-session")
105
+ # Write exactly 10,000 lines (at threshold, no rotation yet)
106
+ lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
107
+ sf.file_path.write_text("".join(lines))
108
+
109
+ # Import StateEntry to create a valid entry
110
+ from claude_statusline.core.state import StateEntry
111
+
112
+ entry = StateEntry(
113
+ timestamp=1710298000,
114
+ total_input_tokens=100,
115
+ total_output_tokens=200,
116
+ current_input_tokens=300,
117
+ current_output_tokens=400,
118
+ cache_creation=500,
119
+ cache_read=600,
120
+ cost_usd=0.01,
121
+ lines_added=10,
122
+ lines_removed=5,
123
+ session_id="test-session",
124
+ model_id="model",
125
+ workspace_project_dir="/tmp/proj",
126
+ context_window_size=200000,
127
+ )
128
+ sf.append_entry(entry)
129
+
130
+ # Now file had 10,001 lines -> should have been rotated to 5,000
131
+ result_lines = sf.file_path.read_text().splitlines()
132
+ assert len(result_lines) == 5_000
133
+
134
+ def test_no_temp_files_left_after_rotation(self, tmp_path, monkeypatch):
135
+ """No .tmp files remain after successful rotation."""
136
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
137
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
138
+ (tmp_path / "old").mkdir()
139
+
140
+ sf = StateFile("test-session")
141
+ lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
142
+ sf.file_path.write_text("".join(lines))
143
+
144
+ sf._maybe_rotate()
145
+
146
+ tmp_files = list(tmp_path.glob("*.tmp"))
147
+ assert tmp_files == []
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Session ID Validation
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class TestSessionIdValidation:
156
+ """Tests for _validate_session_id and StateFile constructor validation."""
157
+
158
+ def test_reject_forward_slash(self):
159
+ with pytest.raises(ValueError, match="/"):
160
+ _validate_session_id("../../etc/passwd")
161
+
162
+ def test_reject_backslash(self):
163
+ with pytest.raises(ValueError, match=re.escape("\\")):
164
+ _validate_session_id("..\\..\\etc\\passwd")
165
+
166
+ def test_reject_dot_dot(self):
167
+ with pytest.raises(ValueError, match=r"\.\."):
168
+ _validate_session_id("..hidden")
169
+
170
+ def test_reject_null_byte(self):
171
+ with pytest.raises(ValueError):
172
+ _validate_session_id("session\0id")
173
+
174
+ def test_accept_valid_uuid(self):
175
+ _validate_session_id("abc-123-def-456") # Should not raise
176
+
177
+ def test_accept_hyphens_underscores(self):
178
+ _validate_session_id("my_session-id_123") # Should not raise
179
+
180
+ def test_accept_alphanumeric(self):
181
+ _validate_session_id("abcdef1234567890") # Should not raise
182
+
183
+ def test_statefile_rejects_invalid_session(self, tmp_path, monkeypatch):
184
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
185
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
186
+ (tmp_path / "old").mkdir()
187
+
188
+ with pytest.raises(ValueError):
189
+ StateFile("../../etc/passwd")
190
+
191
+ def test_statefile_accepts_none_session(self, tmp_path, monkeypatch):
192
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
193
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
194
+ (tmp_path / "old").mkdir()
195
+
196
+ sf = StateFile(None) # Should not raise
197
+ assert sf.session_id is None
198
+
199
+ def test_statefile_accepts_valid_session(self, tmp_path, monkeypatch):
200
+ monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
201
+ monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
202
+ (tmp_path / "old").mkdir()
203
+
204
+ sf = StateFile("valid-session-123") # Should not raise
205
+ assert sf.session_id == "valid-session-123"
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # CLI Session ID Rejection (subprocess test)
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ class TestCliSessionIdRejection:
214
+ """Test that context-stats CLI rejects invalid session IDs."""
215
+
216
+ def test_cli_rejects_path_traversal(self):
217
+ result = subprocess.run(
218
+ [sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd"],
219
+ capture_output=True,
220
+ text=True,
221
+ )
222
+ assert result.returncode != 0
223
+ assert "Invalid session_id" in result.stderr
224
+
225
+ def test_cli_rejects_backslash(self):
226
+ result = subprocess.run(
227
+ [sys.executable, "-m", "claude_statusline.cli.context_stats", "test\\bad"],
228
+ capture_output=True,
229
+ text=True,
230
+ )
231
+ assert result.returncode != 0
232
+ assert "Invalid session_id" in result.stderr
@@ -1,5 +1,7 @@
1
1
  """Tests for statusline.py script."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import json
4
6
  import os
5
7
  import re
@@ -1,17 +0,0 @@
1
- ---
2
- description: Display ASCII graph of token usage for current session
3
- argument-hint: [session_id] [--type cumulative|delta|both] [--no-color]
4
- allowed-tools: Bash(*)
5
- ---
6
-
7
- # Context Stats
8
-
9
- Display the token consumption history as ASCII graphs.
10
-
11
- **IMPORTANT**: This command executes a bash script and displays its output directly. Do NOT analyze or interpret the results - simply show them to the user.
12
-
13
- Execute the token graph script:
14
-
15
- !`bash /Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh $ARGUMENTS`
16
-
17
- The output above shows the token usage visualization. No further analysis is needed.
@@ -1,117 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/statusline-full.sh:*)",
5
- "Bash(find:*)",
6
- "Bash(GIT_DIR=/Users/montimage/buildspace/luongnv89/claude-statusline/.git git add scripts/)",
7
- "Bash(GIT_DIR=/Users/montimage/buildspace/luongnv89/claude-statusline/.git git -c core.hooksPath=/dev/null commit:*)",
8
- "Bash(GIT_SSH_COMMAND=\"ssh -F /dev/null -o IdentityFile=~/.ssh/id_ed25519\" git push origin main)",
9
- "Bash(/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/statusline-original.sh)",
10
- "Bash(/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/statusline-git.sh)",
11
- "Bash(/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/statusline-minimal.sh:*)",
12
- "Bash(ls:*)",
13
- "Bash(wc:*)",
14
- "Bash(pytest:*)",
15
- "Bash(bats:*)",
16
- "Bash(ruff check:*)",
17
- "Bash(npx eslint:*)",
18
- "Bash(shellcheck:*)",
19
- "Bash(gh run list:*)",
20
- "Bash(gh run view:*)",
21
- "Bash(ruff format:*)",
22
- "Bash(git rm:*)",
23
- "Bash(scripts/statusline-full.sh:*)",
24
- "Bash(./scripts/statusline-full.sh:*)",
25
- "Bash(~/.claude/statusline.conf)",
26
- "Bash(/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh:*)",
27
- "Bash(bash -x:*)",
28
- "Bash(timeout 5 bash:*)",
29
- "Bash(perl -e 'alarm 10; exec @ARGV' -- /Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh test-session --no-color)",
30
- "Bash(bash -c '/Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh test-session --no-color; echo \"\"Exit code: $?\"\"')",
31
- "Bash(/bin/bash /Users/montimage/buildspace/luongnv89/claude-statusline/scripts/context-stats.sh:*)",
32
- "Bash(/bin/bash:*)",
33
- "Bash(/opt/homebrew/bin/bash:*)",
34
- "Bash(COLUMNS=80 LINES=40 /bin/bash:*)",
35
- "Bash(pre-commit:*)",
36
- "Bash(./install.sh)",
37
- "Bash(context-stats:*)",
38
- "Bash(gh pr list:*)",
39
- "Bash(apt-get update:*)",
40
- "Bash(apt-get install:*)",
41
- "Bash(pip install:*)",
42
- "Bash(mypy:*)",
43
- "Bash(npm ci:*)",
44
- "Bash(npx prettier --check scripts/statusline.js)",
45
- "Bash(cat:*)",
46
- "Bash(node -c:*)",
47
- "Bash(xxd:*)",
48
- "Bash(node --version:*)",
49
- "Bash(awk:*)",
50
- "Bash(grep:*)",
51
- "Bash(npm test:*)",
52
- "Bash(SAMPLE_INPUT='{\"\"\"\"model\"\"\"\":{\"\"\"\"display_name\"\"\"\":\"\"\"\"Claude 3.5 Sonnet\"\"\"\"},\"\"\"\"workspace\"\"\"\":{\"\"\"\"current_dir\"\"\"\":\"\"\"\"/tmp/test\"\"\"\",\"\"\"\"project_dir\"\"\"\":\"\"\"\"/tmp/test\"\"\"\"},\"\"\"\"context_window\"\"\"\":{\"\"\"\"context_window_size\"\"\"\":200000,\"\"\"\"current_usage\"\"\"\":{\"\"\"\"input_tokens\"\"\"\":1000,\"\"\"\"cache_creation_input_tokens\"\"\"\":500,\"\"\"\"cache_read_input_tokens\"\"\"\":200}}}')",
53
- "Bash(echo \"Testing bash scripts...\" echo \"$SAMPLE_INPUT\")",
54
- "Bash(./scripts/statusline-git.sh echo \"$SAMPLE_INPUT\")",
55
- "Bash(./scripts/statusline-minimal.sh:*)",
56
- "Bash(echo \"Testing Python script...\" echo \"$SAMPLE_INPUT\")",
57
- "Bash(python3:*)",
58
- "Bash(echo \"Testing Node.js script...\" echo \"$SAMPLE_INPUT\")",
59
- "Bash(node ./scripts/statusline.js:*)",
60
- "Bash(./scripts/statusline-git.sh echo \"\" echo \"$SAMPLE_INPUT\")",
61
- "Bash(export PATH=\"$HOME/.local/bin:$PATH\")",
62
- "Bash(~/.local/bin/context-stats test-session:*)",
63
- "Bash(~/.local/bin/context-stats)",
64
- "Bash(~/.local/bin/context-stats:*)",
65
- "Bash(gh run watch:*)",
66
- "Bash(claude-statusline)",
67
- "Bash(python -m build:*)",
68
- "Bash(unzip:*)",
69
- "Bash(twine:*)",
70
- "WebFetch(domain:pypi.org)",
71
- "Bash(python -m pytest:*)",
72
- "Bash(python:*)",
73
- "Bash(python -c:*)",
74
- "Bash(timeout 5 context-stats:*)",
75
- "Bash(gtimeout 5 context-stats:*)",
76
- "Bash(./scripts/context-stats.sh:*)",
77
- "Bash(echo:*)",
78
- "Bash(CLAUDE_SESSION_ID=\"bf8a8370-18d6-473d-8f12-51e7f3c2a9d0\" node scripts/statusline.js:*)",
79
- "Skill(pre-commit)",
80
- "Bash(npm run format:check:*)",
81
- "Bash(bash:*)",
82
- "Bash(git tag:*)",
83
- "Bash(git pull:*)",
84
- "Bash(git rebase:*)",
85
- "Bash(git stash:*)",
86
- "Bash(npm whoami:*)",
87
- "Bash(git filter-branch:*)",
88
- "Bash(gh issue view:*)",
89
- "Bash(head:*)",
90
- "Bash(/Users/montimage/buildspace/luongnv89/cc-context-stats/.venv/bin/python -m pytest:*)",
91
- "Bash(/Users/montimage/buildspace/luongnv89/cc-context-stats/.venv/bin/python3 -m pytest:*)",
92
- "Bash(/Users/montimage/buildspace/luongnv89/cc-context-stats/.venv/bin/python3.14 -m pytest:*)",
93
- "Bash(/Users/montimage/buildspace/luongnv89/cc-context-stats/venv/bin/python3 -m pytest:*)",
94
- "Bash(/Users/montimage/buildspace/luongnv89/cc-context-stats/venv/bin/pip install:*)",
95
- "Bash(.test_venv/bin/pip install:*)",
96
- "Bash(.test_venv/bin/python3.14 -m pytest:*)",
97
- "Bash(.venv/bin/pip install:*)",
98
- "Bash(.venv/bin/python3.14 -m pytest:*)",
99
- "Bash(.venv/bin/context-stats:*)",
100
- "Bash(npx jest:*)",
101
- "Bash(gh workflow:*)",
102
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats status)",
103
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats diff)",
104
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats log --oneline -5)",
105
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats add tests/python/test_statusline.py)",
106
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats commit -m \"$\\(cat <<''EOF''\nfix: resolve Windows CI failures caused by Unicode encoding in Python tests\n\nSet PYTHONUTF8=1 for subprocess calls and use explicit utf-8 encoding\nfor read_text\\(\\) to handle Unicode characters \\(emojis/icons\\) on Windows\nwhere the default cp1252 encoding cannot decode them.\nEOF\n\\)\")",
107
- "Bash(git -C /Users/montimage/buildspace/luongnv89/cc-context-stats push)",
108
- "Bash(COLUMNS=200 cat tests/fixtures/json/valid_full.json | scripts/statusline-full.sh 2>&1)",
109
- "Bash(export COLUMNS=200 && cat tests/fixtures/json/valid_full.json | scripts/statusline-full.sh 2>&1)",
110
- "Bash(gh release:*)",
111
- "Read(//Users/montimage/.claude/**)",
112
- "Bash(git checkout:*)",
113
- "Bash(npm publish:*)",
114
- "Bash(npm view:*)"
115
- ]
116
- }
117
- }