cc-context-stats 1.3.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 (72) hide show
  1. package/.claude/commands/context-stats.md +17 -0
  2. package/.claude/settings.local.json +85 -0
  3. package/.editorconfig +60 -0
  4. package/.eslintrc.json +35 -0
  5. package/.github/dependabot.yml +44 -0
  6. package/.github/workflows/ci.yml +255 -0
  7. package/.github/workflows/release.yml +149 -0
  8. package/.pre-commit-config.yaml +74 -0
  9. package/.prettierrc +33 -0
  10. package/.shellcheckrc +10 -0
  11. package/CHANGELOG.md +100 -0
  12. package/CONTRIBUTING.md +240 -0
  13. package/PUBLISHING_GUIDE.md +69 -0
  14. package/README.md +179 -0
  15. package/config/settings-example.json +7 -0
  16. package/config/settings-node.json +7 -0
  17. package/config/settings-python.json +7 -0
  18. package/docs/configuration.md +83 -0
  19. package/docs/context-stats.md +132 -0
  20. package/docs/installation.md +195 -0
  21. package/docs/scripts.md +116 -0
  22. package/docs/troubleshooting.md +189 -0
  23. package/images/claude-statusline-token-graph.gif +0 -0
  24. package/images/claude-statusline.png +0 -0
  25. package/images/context-status-dumbzone.png +0 -0
  26. package/images/context-status.png +0 -0
  27. package/images/statusline-detail.png +0 -0
  28. package/images/token-graph.jpeg +0 -0
  29. package/images/token-graph.png +0 -0
  30. package/install +344 -0
  31. package/install.sh +272 -0
  32. package/jest.config.js +11 -0
  33. package/npm-publish.sh +33 -0
  34. package/package.json +36 -0
  35. package/publish.sh +24 -0
  36. package/pyproject.toml +113 -0
  37. package/requirements-dev.txt +12 -0
  38. package/scripts/context-stats.sh +970 -0
  39. package/scripts/statusline-full.sh +241 -0
  40. package/scripts/statusline-git.sh +32 -0
  41. package/scripts/statusline-minimal.sh +11 -0
  42. package/scripts/statusline.js +350 -0
  43. package/scripts/statusline.py +312 -0
  44. package/show_raw_claude_code_api.js +11 -0
  45. package/src/claude_statusline/__init__.py +11 -0
  46. package/src/claude_statusline/__main__.py +6 -0
  47. package/src/claude_statusline/cli/__init__.py +1 -0
  48. package/src/claude_statusline/cli/context_stats.py +379 -0
  49. package/src/claude_statusline/cli/statusline.py +172 -0
  50. package/src/claude_statusline/core/__init__.py +1 -0
  51. package/src/claude_statusline/core/colors.py +55 -0
  52. package/src/claude_statusline/core/config.py +98 -0
  53. package/src/claude_statusline/core/git.py +67 -0
  54. package/src/claude_statusline/core/state.py +266 -0
  55. package/src/claude_statusline/formatters/__init__.py +1 -0
  56. package/src/claude_statusline/formatters/time.py +50 -0
  57. package/src/claude_statusline/formatters/tokens.py +70 -0
  58. package/src/claude_statusline/graphs/__init__.py +1 -0
  59. package/src/claude_statusline/graphs/renderer.py +346 -0
  60. package/src/claude_statusline/graphs/statistics.py +58 -0
  61. package/tests/bash/test_install.bats +29 -0
  62. package/tests/bash/test_statusline_full.bats +109 -0
  63. package/tests/bash/test_statusline_git.bats +42 -0
  64. package/tests/bash/test_statusline_minimal.bats +37 -0
  65. package/tests/fixtures/json/high_usage.json +17 -0
  66. package/tests/fixtures/json/low_usage.json +17 -0
  67. package/tests/fixtures/json/medium_usage.json +17 -0
  68. package/tests/fixtures/json/valid_full.json +30 -0
  69. package/tests/fixtures/json/valid_minimal.json +9 -0
  70. package/tests/node/statusline.test.js +199 -0
  71. package/tests/python/conftest.py +84 -0
  72. package/tests/python/test_statusline.py +154 -0
@@ -0,0 +1,30 @@
1
+ {
2
+ "model": {
3
+ "display_name": "Opus 4.5",
4
+ "api_name": "claude-opus-4-5"
5
+ },
6
+ "workspace": {
7
+ "current_dir": "/home/user/my-project",
8
+ "project_dir": "/home/user/my-project"
9
+ },
10
+ "context_window": {
11
+ "context_window_size": 200000,
12
+ "total_input_tokens": 75000,
13
+ "total_output_tokens": 8500,
14
+ "current_usage": {
15
+ "input_tokens": 50000,
16
+ "output_tokens": 5000,
17
+ "cache_creation_input_tokens": 10000,
18
+ "cache_read_input_tokens": 20000
19
+ }
20
+ },
21
+ "cost": {
22
+ "total_cost_usd": 0.05234,
23
+ "total_duration_ms": 120000,
24
+ "total_api_duration_ms": 5000,
25
+ "total_lines_added": 250,
26
+ "total_lines_removed": 45
27
+ },
28
+ "session_id": "test-session-123",
29
+ "version": "1.0.80"
30
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "model": {
3
+ "display_name": "Claude"
4
+ },
5
+ "workspace": {
6
+ "current_dir": "/tmp/test",
7
+ "project_dir": "/tmp/test"
8
+ }
9
+ }
@@ -0,0 +1,199 @@
1
+ const { spawn } = require('child_process');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'statusline.js');
6
+ const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures', 'json');
7
+
8
+ /**
9
+ * Run the statusline.js script with the given input data
10
+ * @param {Object|string} inputData - JSON input or string
11
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>}
12
+ */
13
+ function runScript(inputData) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn('node', [SCRIPT_PATH]);
16
+ let stdout = '';
17
+ let stderr = '';
18
+
19
+ child.stdout.on('data', data => {
20
+ stdout += data.toString();
21
+ });
22
+
23
+ child.stderr.on('data', data => {
24
+ stderr += data.toString();
25
+ });
26
+
27
+ child.on('close', code => {
28
+ resolve({ stdout: stdout.trim(), stderr, code });
29
+ });
30
+
31
+ child.on('error', reject);
32
+
33
+ const input = typeof inputData === 'string' ? inputData : JSON.stringify(inputData);
34
+ child.stdin.write(input);
35
+ child.stdin.end();
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Load a JSON fixture file
41
+ * @param {string} name - Fixture name without .json extension
42
+ * @returns {Object}
43
+ */
44
+ function loadFixture(name) {
45
+ const filePath = path.join(FIXTURES_DIR, `${name}.json`);
46
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
47
+ }
48
+
49
+ describe('statusline.js', () => {
50
+ const sampleInput = {
51
+ model: { display_name: 'Claude 3.5 Sonnet' },
52
+ workspace: {
53
+ current_dir: '/home/user/myproject',
54
+ project_dir: '/home/user/myproject',
55
+ },
56
+ context_window: {
57
+ context_window_size: 200000,
58
+ current_usage: {
59
+ input_tokens: 10000,
60
+ cache_creation_input_tokens: 500,
61
+ cache_read_input_tokens: 200,
62
+ },
63
+ },
64
+ };
65
+
66
+ describe('Script basics', () => {
67
+ test('script file exists', () => {
68
+ expect(fs.existsSync(SCRIPT_PATH)).toBe(true);
69
+ });
70
+
71
+ test('script has node shebang', () => {
72
+ const content = fs.readFileSync(SCRIPT_PATH, 'utf8');
73
+ expect(content.startsWith('#!/usr/bin/env node')).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe('Output content', () => {
78
+ test('outputs model name', async () => {
79
+ const result = await runScript(sampleInput);
80
+ expect(result.stdout).toContain('Claude 3.5 Sonnet');
81
+ expect(result.code).toBe(0);
82
+ });
83
+
84
+ test('outputs directory name', async () => {
85
+ const result = await runScript(sampleInput);
86
+ expect(result.stdout).toContain('myproject');
87
+ });
88
+
89
+ test('shows free tokens indicator', async () => {
90
+ const result = await runScript(sampleInput);
91
+ expect(result.stdout).toContain('free');
92
+ });
93
+
94
+ test('shows AC indicator', async () => {
95
+ const result = await runScript(sampleInput);
96
+ expect(result.stdout).toContain('[AC:');
97
+ });
98
+
99
+ test('shows percentage', async () => {
100
+ const result = await runScript(sampleInput);
101
+ expect(result.stdout).toMatch(/\d+\.\d+%/);
102
+ });
103
+ });
104
+
105
+ describe('Error handling', () => {
106
+ test('handles missing model gracefully', async () => {
107
+ const input = {
108
+ workspace: { current_dir: '/tmp/test', project_dir: '/tmp/test' },
109
+ };
110
+ const result = await runScript(input);
111
+ expect(result.stdout).toContain('Claude'); // Default fallback
112
+ expect(result.code).toBe(0);
113
+ });
114
+
115
+ test('handles missing context window gracefully', async () => {
116
+ const input = {
117
+ model: { display_name: 'Claude' },
118
+ workspace: { current_dir: '/tmp/test', project_dir: '/tmp/test' },
119
+ };
120
+ const result = await runScript(input);
121
+ expect(result.code).toBe(0);
122
+ });
123
+
124
+ test('handles invalid JSON gracefully', async () => {
125
+ const result = await runScript('invalid json');
126
+ expect(result.code).toBe(0);
127
+ expect(result.stdout).toContain('Claude');
128
+ });
129
+
130
+ test('handles empty input gracefully', async () => {
131
+ const result = await runScript('');
132
+ expect(result.code).toBe(0);
133
+ });
134
+ });
135
+
136
+ describe('Fixtures', () => {
137
+ test('handles valid_full fixture', async () => {
138
+ const input = loadFixture('valid_full');
139
+ const result = await runScript(input);
140
+ expect(result.code).toBe(0);
141
+ expect(result.stdout).toContain('Opus 4.5');
142
+ expect(result.stdout).toContain('my-project');
143
+ });
144
+
145
+ test('handles valid_minimal fixture', async () => {
146
+ const input = loadFixture('valid_minimal');
147
+ const result = await runScript(input);
148
+ expect(result.code).toBe(0);
149
+ expect(result.stdout).toContain('Claude');
150
+ });
151
+
152
+ test('handles low_usage fixture', async () => {
153
+ const input = loadFixture('low_usage');
154
+ const result = await runScript(input);
155
+ expect(result.code).toBe(0);
156
+ expect(result.stdout).toContain('free');
157
+ });
158
+
159
+ test('handles medium_usage fixture', async () => {
160
+ const input = loadFixture('medium_usage');
161
+ const result = await runScript(input);
162
+ expect(result.code).toBe(0);
163
+ expect(result.stdout).toContain('free');
164
+ });
165
+
166
+ test('handles high_usage fixture', async () => {
167
+ const input = loadFixture('high_usage');
168
+ const result = await runScript(input);
169
+ expect(result.code).toBe(0);
170
+ expect(result.stdout).toContain('free');
171
+ });
172
+
173
+ test('all JSON fixtures succeed', async () => {
174
+ const fixtures = fs.readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json'));
175
+ for (const fixture of fixtures) {
176
+ const input = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, fixture), 'utf8'));
177
+ const result = await runScript(input);
178
+ expect(result.code).toBe(0);
179
+ }
180
+ });
181
+ });
182
+
183
+ describe('Session ID display', () => {
184
+ test('shows session_id by default', async () => {
185
+ const inputWithSession = {
186
+ ...sampleInput,
187
+ session_id: 'test-session-abc123',
188
+ };
189
+ const result = await runScript(inputWithSession);
190
+ expect(result.code).toBe(0);
191
+ expect(result.stdout).toContain('test-session-abc123');
192
+ });
193
+
194
+ test('handles missing session_id gracefully', async () => {
195
+ const result = await runScript(sampleInput);
196
+ expect(result.code).toBe(0);
197
+ });
198
+ });
199
+ });
@@ -0,0 +1,84 @@
1
+ """Pytest configuration and fixtures for statusline tests."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ # Get the project root directory
9
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
10
+ FIXTURES_DIR = PROJECT_ROOT / "tests" / "fixtures" / "json"
11
+ SCRIPTS_DIR = PROJECT_ROOT / "scripts"
12
+
13
+
14
+ @pytest.fixture
15
+ def project_root():
16
+ """Return the project root directory."""
17
+ return PROJECT_ROOT
18
+
19
+
20
+ @pytest.fixture
21
+ def scripts_dir():
22
+ """Return the scripts directory."""
23
+ return SCRIPTS_DIR
24
+
25
+
26
+ @pytest.fixture
27
+ def fixtures_dir():
28
+ """Return the fixtures directory."""
29
+ return FIXTURES_DIR
30
+
31
+
32
+ @pytest.fixture
33
+ def valid_full_input():
34
+ """Load valid_full.json fixture."""
35
+ with open(FIXTURES_DIR / "valid_full.json") as f:
36
+ return json.load(f)
37
+
38
+
39
+ @pytest.fixture
40
+ def valid_minimal_input():
41
+ """Load valid_minimal.json fixture."""
42
+ with open(FIXTURES_DIR / "valid_minimal.json") as f:
43
+ return json.load(f)
44
+
45
+
46
+ @pytest.fixture
47
+ def low_usage_input():
48
+ """Load low_usage.json fixture."""
49
+ with open(FIXTURES_DIR / "low_usage.json") as f:
50
+ return json.load(f)
51
+
52
+
53
+ @pytest.fixture
54
+ def medium_usage_input():
55
+ """Load medium_usage.json fixture."""
56
+ with open(FIXTURES_DIR / "medium_usage.json") as f:
57
+ return json.load(f)
58
+
59
+
60
+ @pytest.fixture
61
+ def high_usage_input():
62
+ """Load high_usage.json fixture."""
63
+ with open(FIXTURES_DIR / "high_usage.json") as f:
64
+ return json.load(f)
65
+
66
+
67
+ @pytest.fixture
68
+ def sample_input():
69
+ """Return a sample input dictionary for testing."""
70
+ return {
71
+ "model": {"display_name": "Claude 3.5 Sonnet"},
72
+ "workspace": {
73
+ "current_dir": "/home/user/myproject",
74
+ "project_dir": "/home/user/myproject",
75
+ },
76
+ "context_window": {
77
+ "context_window_size": 200000,
78
+ "current_usage": {
79
+ "input_tokens": 10000,
80
+ "cache_creation_input_tokens": 500,
81
+ "cache_read_input_tokens": 200,
82
+ },
83
+ },
84
+ }
@@ -0,0 +1,154 @@
1
+ """Tests for statusline.py script."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ SCRIPT_PATH = Path(__file__).parent.parent.parent / "scripts" / "statusline.py"
9
+
10
+
11
+ def run_script(input_data: dict) -> tuple[str, int]:
12
+ """Run the statusline.py script with the given input.
13
+
14
+ Returns:
15
+ Tuple of (stdout, return_code)
16
+ """
17
+ result = subprocess.run(
18
+ [sys.executable, str(SCRIPT_PATH)],
19
+ input=json.dumps(input_data),
20
+ capture_output=True,
21
+ text=True,
22
+ )
23
+ return result.stdout.strip(), result.returncode
24
+
25
+
26
+ class TestStatuslineScript:
27
+ """Tests for the statusline.py script execution."""
28
+
29
+ def test_script_exists(self):
30
+ """Script file should exist."""
31
+ assert SCRIPT_PATH.exists()
32
+
33
+ def test_script_is_python(self):
34
+ """Script should have Python shebang."""
35
+ content = SCRIPT_PATH.read_text()
36
+ assert content.startswith("#!/usr/bin/env python3")
37
+
38
+ def test_outputs_model_name(self, sample_input):
39
+ """Should output the model name."""
40
+ output, code = run_script(sample_input)
41
+ assert code == 0
42
+ assert "Claude 3.5 Sonnet" in output
43
+
44
+ def test_outputs_directory_name(self, sample_input):
45
+ """Should output the directory name."""
46
+ output, code = run_script(sample_input)
47
+ assert code == 0
48
+ assert "myproject" in output
49
+
50
+ def test_shows_free_tokens(self, sample_input):
51
+ """Should show free tokens indicator."""
52
+ output, code = run_script(sample_input)
53
+ assert code == 0
54
+ assert "free" in output
55
+
56
+ def test_shows_ac_indicator(self, sample_input):
57
+ """Should show autocompact indicator."""
58
+ output, code = run_script(sample_input)
59
+ assert code == 0
60
+ assert "[AC:" in output
61
+
62
+ def test_handles_missing_model(self):
63
+ """Should handle missing model gracefully."""
64
+ input_data = {"workspace": {"current_dir": "/tmp/test", "project_dir": "/tmp/test"}}
65
+ output, code = run_script(input_data)
66
+ assert code == 0
67
+ assert "Claude" in output # Default fallback
68
+
69
+ def test_handles_invalid_json(self):
70
+ """Should handle invalid JSON gracefully."""
71
+ result = subprocess.run(
72
+ [sys.executable, str(SCRIPT_PATH)],
73
+ input="invalid json",
74
+ capture_output=True,
75
+ text=True,
76
+ )
77
+ assert result.returncode == 0
78
+ assert "Claude" in result.stdout
79
+
80
+ def test_handles_empty_input(self):
81
+ """Should handle empty input gracefully."""
82
+ result = subprocess.run(
83
+ [sys.executable, str(SCRIPT_PATH)],
84
+ input="",
85
+ capture_output=True,
86
+ text=True,
87
+ )
88
+ assert result.returncode == 0
89
+
90
+
91
+ class TestContextWindowColors:
92
+ """Tests for context window color coding."""
93
+
94
+ def test_low_usage_has_output(self, low_usage_input):
95
+ """Low usage (>50% free) should produce output with 'free'."""
96
+ output, code = run_script(low_usage_input)
97
+ assert code == 0
98
+ assert "free" in output
99
+
100
+ def test_medium_usage_has_output(self, medium_usage_input):
101
+ """Medium usage (25-50% free) should produce output with 'free'."""
102
+ output, code = run_script(medium_usage_input)
103
+ assert code == 0
104
+ assert "free" in output
105
+
106
+ def test_high_usage_has_output(self, high_usage_input):
107
+ """High usage (<25% free) should produce output with 'free'."""
108
+ output, code = run_script(high_usage_input)
109
+ assert code == 0
110
+ assert "free" in output
111
+
112
+
113
+ class TestFixtures:
114
+ """Tests using fixture files."""
115
+
116
+ def test_valid_full_fixture(self, valid_full_input):
117
+ """Should handle valid_full.json fixture."""
118
+ output, code = run_script(valid_full_input)
119
+ assert code == 0
120
+ assert "Opus 4.5" in output
121
+ assert "my-project" in output
122
+
123
+ def test_valid_minimal_fixture(self, valid_minimal_input):
124
+ """Should handle valid_minimal.json fixture."""
125
+ output, code = run_script(valid_minimal_input)
126
+ assert code == 0
127
+ assert "Claude" in output
128
+
129
+ def test_all_fixtures_succeed(self, fixtures_dir):
130
+ """All JSON fixtures should be processed without errors."""
131
+ for fixture_file in fixtures_dir.glob("*.json"):
132
+ with open(fixture_file) as f:
133
+ input_data = json.load(f)
134
+ output, code = run_script(input_data)
135
+ assert code == 0, f"Failed on fixture: {fixture_file.name}"
136
+
137
+
138
+ class TestSessionDisplay:
139
+ """Tests for session_id display feature."""
140
+
141
+ def test_shows_session_id_by_default(self, sample_input):
142
+ """Should show session_id by default (show_session=true)."""
143
+ sample_input["session_id"] = "test-session-12345"
144
+ output, code = run_script(sample_input)
145
+ assert code == 0
146
+ assert "test-session-12345" in output
147
+
148
+ def test_handles_missing_session_id(self, sample_input):
149
+ """Should handle missing session_id gracefully."""
150
+ # Ensure no session_id in input
151
+ if "session_id" in sample_input:
152
+ del sample_input["session_id"]
153
+ output, code = run_script(sample_input)
154
+ assert code == 0