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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
- package/.github/workflows/ci.yml +39 -2
- package/CHANGELOG.md +16 -8
- package/CLAUDE.md +54 -0
- package/CODE_OF_CONDUCT.md +59 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/RELEASE_NOTES.md +16 -7
- package/SECURITY.md +44 -0
- package/TODOS.md +72 -0
- package/docs/ARCHITECTURE.md +101 -0
- package/docs/CSV_FORMAT.md +40 -0
- package/docs/DEPLOYMENT.md +60 -0
- package/docs/DEVELOPMENT.md +125 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +1 -1
- package/scripts/statusline.js +61 -8
- package/scripts/statusline.py +9 -8
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +20 -1
- package/src/claude_statusline/core/config.py +5 -4
- package/src/claude_statusline/core/state.py +64 -7
- package/tests/bash/test_parity.bats +315 -0
- package/tests/fixtures/json/comma_in_path.json +31 -0
- package/tests/node/rotation.test.js +89 -0
- package/tests/python/test_data_pipeline.py +446 -0
- package/tests/python/test_state_rotation_validation.py +232 -0
- package/.claude/commands/context-stats.md +0 -17
- package/.claude/settings.local.json +0 -120
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)
|
package/scripts/statusline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
+
}
|
package/scripts/statusline.py
CHANGED
|
@@ -159,8 +159,8 @@ show_delta=true
|
|
|
159
159
|
show_session=true
|
|
160
160
|
"""
|
|
161
161
|
)
|
|
162
|
-
except Exception:
|
|
163
|
-
|
|
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
|
|
186
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|