Skip to content

feat: add write_state_file functionality for external integrations#184

Open
mossglenn wants to merge 7 commits intoMaciek-roboblog:mainfrom
mossglenn:main
Open

feat: add write_state_file functionality for external integrations#184
mossglenn wants to merge 7 commits intoMaciek-roboblog:mainfrom
mossglenn:main

Conversation

@mossglenn
Copy link

@mossglenn mossglenn commented Feb 5, 2026

Summary

Adds --write-state flag to enable writing current monitoring state to ~/.claude-monitor/reports/current.json for consumption by external applications (VS Code extensions, status bar widgets, dashboards).

Motivation

External tools (IDE extensions, system widgets) need access to real-time Claude usage statistics without parsing terminal output. This feature provides a clean JSON API for integration.

Changes

  • Added write_state boolean field to Settings with CLI flag support
  • Implemented _write_state_file() method in DisplayController
  • Added state file writing logic to data display flow
  • Created comprehensive STATE_FILE.md documentation with TypeScript/Python types
  • Updated README.md with feature information and usage examples
  • Added 80+ test cases achieving 72%+ overall coverage

Files Modified

  • src/claude_monitor/cli/bootstrap.py - Environment variable setup
  • src/claude_monitor/cli/main.py - Flag passing to DisplayController
  • src/claude_monitor/core/settings.py - Settings field definition
  • src/claude_monitor/ui/display_controller.py - Core implementation
  • STATE_FILE.md - Comprehensive integration documentation (NEW)
  • README.md - Feature documentation and examples
  • src/tests/test_write_state_file.py - Core tests (NEW, 55 tests)
  • src/tests/test_display_controller.py - Integration tests (14 new tests)
  • src/tests/test_settings.py - Settings tests (14 new tests)

Type of Change

  • New feature (non-breaking)
  • Documentation update
  • Tests added

Testing

  • 80+ unit and integration tests added
  • 72% overall coverage (exceeds 70% minimum)
  • All 596 tests pass
  • Manual testing on macOS completed
  • Tested with all plan types (pro, max5, max20, custom)
  • Tested with multiple timezones and time formats

Documentation

  • STATE_FILE.md created with comprehensive examples
  • TypeScript and Python type definitions provided
  • README.md updated with usage examples
  • Code docstrings complete
  • Integration examples for VS Code, Python, Node.js

Checklist

  • Code follows PEP 8 / project style guidelines
  • All tests pass locally (pytest)
  • Linting passes (ruff check)
  • Formatting passes (ruff format)
  • Pre-commit hooks pass
  • Documentation is complete and accurate
  • Commit messages follow convention (feat/fix/docs/test)
  • No breaking changes
  • Feature is opt-in (default: disabled)

Example Output

```json
{
"messages": {"used": 140, "limit": 250, "percent": 56.0},
"tokens": {"used": 17582, "limit": 38705, "percent": 45.43},
"cost": {"used": 5.13, "limit": 50.0, "percent": 10.25},
"reset": {
"timestamp": "2026-01-10T18:00:00+00:00",
"secondsRemaining": 7920,
"formattedTime": "6:00 PM"
},
"burnRate": {"tokens": 32859.04, "messages": 0},
"lastUpdate": "2026-01-10T15:48:00.123456"
}
```

Breaking Changes

None - feature is completely opt-in via --write-state flag.

Use Cases

  • VS Code extensions showing usage in status bar
  • System status bar widgets (macOS, Linux, Windows)
  • Custom dashboards and monitoring tools
  • Scripts monitoring usage patterns
  • Alerts based on usage thresholds

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a --write-state CLI flag to optionally export current monitoring state to a JSON file for external tool integration
    • State file includes tokens, cost, messages, reset info, burn rate, and timestamps
  • Documentation

    • Added comprehensive state file docs with usage examples and README integration guidance
    • Documented report directory configuration and environment variable for report location
  • Tests

    • Extensive integration and unit tests covering state writing, format, edge cases, permissions, and timezones

mossglenn and others added 5 commits January 9, 2026 13:33
…tate_file

Fixed two critical bugs that caused monitor to crash on startup:

1. burn_rate type error: `calculate_hourly_burn_rate()` returns a float
   (tokens per minute), not a dict. Changed from `burn_rate.get("tokens_per_minute")`
   to directly using the float value.

2. logger not defined: Added `logger = logging.getLogger(__name__)` inside
   the except block following the codebase's local logger pattern.

These bugs prevented the monitor from displaying data when --write-state
flag was enabled.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…tions

Added complete documentation for the --write-state feature to support
external tool development (VS Code extensions, status bars, dashboards).

New Documentation:
- STATE_FILE.md: Complete JSON schema, field descriptions, and type definitions
  * TypeScript and Python type definitions
  * File watching examples in multiple languages
  * VS Code extension integration example
  * Best practices for consuming the state file
  * Update frequency and staleness detection
  * Troubleshooting guide

README.md Updates:
- Added --write-state flag to CLI parameters table
- Added "External Tool Integration" usage example section
- Added state file to saved parameters list
- Added STATE_FILE.md to Additional Documentation section
- Included state file location and quick overview

This documentation enables developers to build external tools that consume
real-time Claude usage data from ~/.claude-monitor/reports/current.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…isplay

The _write_state_file method was incorrectly calculating reset time based
on reset_hour instead of using the actual session end time from the API.
This caused current.json to show incorrect reset data that didn't match
the terminal display.

Changes:
- Modified _process_active_session_data to include reset_time in the
  returned processed_data dictionary
- Updated _write_state_file to use the pre-calculated reset_time from
  processed_data instead of manually calculating it
- Added proper timezone conversion and time formatting to match terminal
  display logic
- Added null-check error handling for robustness

The reset time now correctly reflects the session's endTime from the API
(when the current 5-hour session expires), not a daily reset hour.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add 80 test cases achieving high coverage for the write_state_file
functionality. Tests cover basic functionality, data accuracy, reset
time calculation, edge cases, error handling, and integration scenarios.

Test files:
- src/tests/test_write_state_file.py (new, 55 tests)
- src/tests/test_display_controller.py (updated, 14 new tests)
- src/tests/test_settings.py (updated, 11 new tests)

Key test categories:
- Basic Functionality: File creation, JSON validation, field presence
- Data Accuracy: Percentages, burn rate type, timestamp formats
- Reset Time Calculation: Regression test for commit 06e880f
- Edge Cases: Zero limits, percentages >100%, various plan types
- Error Handling: Missing directories, permissions, malformed data
- Integration: write_state_enabled flag, CLI flag, Settings field

All tests pass and meet contributor guidelines for core functionality
coverage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Adds a CLI flag --write-state and wiring to write a structured JSON state file (current.json) to a reports directory; includes environment variable for reports dir, settings persistence, state-serialization logic, documentation (STATE_FILE.md), and comprehensive tests.

Changes

Cohort / File(s) Summary
Documentation
README.md, STATE_FILE.md
New documentation for the --write-state feature, state file location, JSON schema, examples (Python/TypeScript/Node), best practices, troubleshooting, and version note (v3.1.0).
Environment & Config
src/claude_monitor/cli/bootstrap.py, src/claude_monitor/core/settings.py
Adds CLAUDE_MONITOR_REPORT_DIR default and a write_state: bool Settings field; persists write_state in LastUsedParams and exposes it via to_namespace.
CLI Wiring & Runtime
src/claude_monitor/cli/main.py, src/claude_monitor/ui/display_controller.py
Wires args.write_state into DisplayController (write_state_enabled), adds _write_state_file to serialize processed state (messages, tokens, cost, reset_time, burnRate, lastUpdate) to JSON, and ensures reset_time is propagated in processed data.
Tests
src/tests/test_display_controller.py, src/tests/test_settings.py, src/tests/test_write_state_file.py
Extensive tests for Settings.write_state, DisplayController write-state integration, state file schema/contents, error and permission handling, time/format/timezone behaviors, and multiple-write scenarios (large test additions).

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI (args)
  participant Settings as Settings/Namespace
  participant Display as DisplayController
  participant FS as FileSystem (reports dir)
  CLI->>Settings: parse args (--write-state)
  Settings-->>Display: args.write_state -> write_state_enabled
  Display->>Display: _process_active_session_data()
  Display-->>FS: _write_state_file(processed_data) [if write_state_enabled]
  FS-->>Display: write result / error
  Display->>CLI: log success or warning
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble bytes and trace the state,
A JSON carrot lands at reports' gate,
Timestamps hop, burn rates softly hum,
External tools wake—oh what fun we've spun! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main feature being added—the write_state_file functionality—and indicates its purpose for external integrations. It directly aligns with the changeset's primary objective.
Docstring Coverage ✅ Passed Docstring coverage is 96.30% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/claude_monitor/ui/display_controller.py`:
- Around line 392-393: The lastUpdate field is generated with
datetime.now().isoformat() and lacks timezone info, causing inconsistency with
reset.timestamp which uses reset_time.isoformat() (UTC); change the generation
of lastUpdate in the display_controller (the dict key 'lastUpdate') to use an
aware UTC timestamp (e.g., datetime.now(timezone.utc).isoformat() or convert to
UTC like reset_time) so both timestamps include the same timezone information
and format.

In `@src/tests/test_display_controller.py`:
- Around line 1256-1263: The test's side_effect function capture_write has the
wrong signature and will receive the Path instance as the first argument; update
capture_write to accept the Path receiver (e.g., self: Path) and the content (or
use *args, **kwargs) so it matches Path.write_text's signature; ensure this
patched function stores the actual text passed and then run
controller._write_state_file(sample_processed_data, sample_args) as before
(referencing capture_write, Path.write_text, and controller._write_state_file to
locate the change).
- Around line 1328-1335: The test's capture_write mock used for Path.write_text
has the wrong signature: the function capture_write defined for the patch of
Path.write_text is missing the first positional parameter that the real method
receives (self); update the capture_write used around
controller._write_state_file to accept (self, content: str) -> None so the
patched call matches Path.write_text, and apply the identical change to the
other capture_write defined at the later occurrence (the one mentioned around
Line 1358) so both mocks mirror Path.write_text's signature.

In `@STATE_FILE.md`:
- Line 328: Update the inaccurate documentation line about the default timezone:
change the stated default from "America/New_York" to "Europe/Warsaw" to match
the actual fallback used in the code (see formattedTime in display_controller.py
which uses "Europe/Warsaw" as the fallback timezone).
- Around line 397-398: The STATE_FILE.md entry for "Reset time" is incorrect:
the state file's reset_time is taken from processed_data (the value computed
from a session's end_time_str in display_controller.py at the code handling
processed_data->reset_time), not from the --reset-hour setting; update the
documentation to state that reset_time is sourced from processed_data (e.g.,
session end_time_str) and that --reset-hour does not change the state file's
reset_time (referenced by
test_reset_time_uses_processed_data_not_reset_hour_calculation and the
processed_data/reset_time field).
🧹 Nitpick comments (12)
src/claude_monitor/ui/display_controller.py (2)

319-322: Remove redundant imports inside the method.

json and os are already imported at the module level (lines 6, 8). timedelta is imported at line 9. Only PathLib alias for Path is unique but unnecessary since Path is already imported at line 10.

♻️ Suggested cleanup
     def _write_state_file(self, processed_data: Dict[str, Any], args: Any) -> None:
         """Write current state to JSON file for external consumers.

         Args:
             processed_data: Processed session data from _process_active_session_data
             args: Command line arguments
         """
         try:
-            import json
-            import os
-            from datetime import timedelta
-            from pathlib import Path as PathLib
-
             # Extract values from processed_data

And update line 396:

-            state_file = PathLib(os.environ["CLAUDE_MONITOR_REPORT_DIR"]) / 'current.json'
+            state_file = Path(os.environ["CLAUDE_MONITOR_REPORT_DIR"]) / 'current.json'

396-397: Consider defensive handling for missing environment variable.

While CLAUDE_MONITOR_REPORT_DIR is set via os.environ.setdefault in bootstrap.py, if someone calls this code path without going through the bootstrap (e.g., in tests or direct API usage), a KeyError would be raised and caught by the generic exception handler, silently failing. Consider using .get() with a fallback.

🛡️ Proposed defensive access
-            state_file = PathLib(os.environ["CLAUDE_MONITOR_REPORT_DIR"]) / 'current.json'
+            report_dir = os.environ.get(
+                "CLAUDE_MONITOR_REPORT_DIR",
+                str(Path.home() / ".claude-monitor" / "reports")
+            )
+            state_file = Path(report_dir) / 'current.json'
STATE_FILE.md (2)

376-381: Add language specifier to fenced code block.

The data flow diagram is missing a language specifier for the code block.

📝 Suggested fix
-   ```
+   ```text
    MonitoringOrchestrator (every --refresh-rate seconds)

387-389: Clarify atomic write behavior.

Path.write_text() is not truly atomic on all platforms. While it typically completes in a single operation, it doesn't use the rename-from-temp-file pattern that guarantees atomicity. The current behavior is generally safe but could leave a partial file on system crash. Consider updating the documentation to be more precise, or implementing a true atomic write if reliability is critical.

📝 Suggested documentation clarification
-3. **Atomic writes**: File is written atomically using `Path.write_text()`
-   - No partial writes under normal conditions
+3. **File writes**: File is written using `Path.write_text()`
+   - Typically completes without partial writes under normal conditions
src/tests/test_write_state_file.py (8)

76-94: Test does not actually verify the intended behavior.

This test creates a controller with write_state_enabled = False but never calls _write_state_file. The assertion that the file doesn't exist is trivially true since no write was attempted. The comment acknowledges this but the test provides no actual verification.

Consider either:

  1. Removing this test since the behavior is tested elsewhere in test_display_controller.py
  2. Testing that calling _write_state_file directly still works (it does) but the integration path in create_data_display doesn't call it
♻️ Suggested improvement
     def test_state_file_not_created_when_disabled(
         self,
         temp_report_dir: Path,
         sample_processed_data: Dict[str, Any],
         sample_args: Mock,
     ) -> None:
-        """Test state file is NOT created when write_state_enabled is False."""
-        with patch("claude_monitor.ui.display_controller.NotificationManager"):
-            controller = DisplayController()
-            controller.write_state_enabled = False
-
-        with patch.dict(
-            os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
-        ):
-            # This should not create the file
-            # Note: The method won't even be called if write_state_enabled is False
-            # in the actual implementation, but we test the flag here
-            state_file = temp_report_dir / "current.json"
-            assert not state_file.exists()
+        """Test _write_state_file is not invoked via create_data_display when disabled."""
+        # This behavior is already tested in test_display_controller.py
+        # TestWriteStateIntegration.test_write_state_file_not_called_when_disabled
+        pytest.skip("Covered by test_display_controller.py integration tests")

268-287: Test name and comment are misleading.

Path.write_text() is not atomic - it opens the file, writes content, and closes it. A crash mid-write could leave partial content. True atomic writes require writing to a temp file then renaming (which is atomic on POSIX).

The test actually verifies "file is complete and parseable after write" rather than atomicity. Consider renaming for accuracy.

♻️ Suggested rename
-    def test_state_file_written_atomically(
+    def test_state_file_written_completely(
         self,
         controller: DisplayController,
         temp_report_dir: Path,
         sample_processed_data: Dict[str, Any],
         sample_args: Mock,
     ) -> None:
-        """Test file is written atomically (using Path.write_text)."""
+        """Test file is written completely and is valid JSON."""
         with patch.dict(
             os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
         ):
-            # The implementation uses Path.write_text which is atomic
             controller._write_state_file(sample_processed_data, sample_args)

292-313: Test verifies the result but not the calculation logic.

The test name implies _write_state_file calculates the percentage from tokens_used/token_limit, but the implementation actually reads usage_percentage directly from processed_data. The test passes because sample_processed_data fixture sets usage_percentage=30.0, which matches the expected value.

This is technically correct but the docstring "Test token percentage calculated correctly: (used/limit)*100" is misleading since that calculation happens upstream, not in _write_state_file.


520-523: No-op string replacement.

Line 522 has timestamp.replace("+00:00", "+00:00") which replaces a string with itself, doing nothing. This appears to be a copy-paste artifact.

♻️ Suggested fix
             # Should be parseable as ISO 8601
             timestamp = data["reset"]["timestamp"]
-            parsed = datetime.fromisoformat(timestamp.replace("+00:00", "+00:00"))
+            parsed = datetime.fromisoformat(timestamp)
             assert parsed.tzinfo is not None

584-587: Fragile decimal place verification.

The string-based check len(str(...).split(".")[-1]) <= 2 has edge cases:

  • Integer 25['25'][-1] is '25' with length 2 (passes but checks integer part)
  • Float 25.0['25', '0'] → works correctly

A more robust approach would compare against the rounded value directly.

♻️ Alternative approach
# Verify rounding by checking value equality
assert data["tokens"]["percent"] == round(data["tokens"]["percent"], 2)
assert data["cost"]["percent"] == round(data["cost"]["percent"], 2)
assert data["messages"]["percent"] == round(data["messages"]["percent"], 2)

686-700: Test uses real time.sleep() which slows execution.

While this test correctly verifies time-dependent behavior, using real delays slows the test suite. Consider mocking datetime.now() to simulate time passage without actual waiting.


722-727: Overly permissive assertion.

In January 2026, Europe/Warsaw is UTC+1 (no DST), so 23:00 UTC should always convert to 00:00 Warsaw time. The assertion accepts either "00:00" or "23:00", which could mask conversion bugs.

♻️ Tighter assertion
             # 23:00 UTC = 00:00 next day Warsaw time
-            # Could be either depending on DST
             formatted = data["reset"]["formattedTime"]
-            assert "00:00" in formatted or "23:00" in formatted
+            assert "00:00" in formatted  # January = UTC+1, no DST

1134-1161: Test lacks concrete assertion on logger call.

The test patches logging.getLogger but doesn't assert that the error method was called. The comment "Logger should have been called" isn't enforced. This test may pass even if logging is broken.

♻️ Add assertion
                 try:
                     controller._write_state_file(sample_processed_data, sample_args)
                 except Exception:
                     pass
 
-                # Logger should have been called for error
-                # (either in exception handler or for warnings)
+                # Verify logger.error was called
+                mock_log_instance.error.assert_called_once()
 
         # Restore permissions
         temp_report_dir.chmod(0o755)

Comment on lines +1256 to +1263
written_content = None

def capture_write(content: str) -> None:
nonlocal written_content
written_content = content

with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent mock signature may cause test failure.

The capture_write function at Line 1258 only accepts content: str, but Path.write_text is an instance method that receives self as the first argument. When used with side_effect, all arguments are passed, so self (the Path instance) would be assigned to content.

Compare with Line 1234 which correctly includes self: Path in the signature.

🔧 Suggested fix
-        def capture_write(content: str) -> None:
+        def capture_write(self: Path, content: str) -> None:
             nonlocal written_content
             written_content = content
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
written_content = None
def capture_write(content: str) -> None:
nonlocal written_content
written_content = content
with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
written_content = None
def capture_write(self: Path, content: str) -> None:
nonlocal written_content
written_content = content
with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
🤖 Prompt for AI Agents
In `@src/tests/test_display_controller.py` around lines 1256 - 1263, The test's
side_effect function capture_write has the wrong signature and will receive the
Path instance as the first argument; update capture_write to accept the Path
receiver (e.g., self: Path) and the content (or use *args, **kwargs) so it
matches Path.write_text's signature; ensure this patched function stores the
actual text passed and then run
controller._write_state_file(sample_processed_data, sample_args) as before
(referencing capture_write, Path.write_text, and controller._write_state_file to
locate the change).

Comment on lines +1328 to +1335
written_content = None

def capture_write(content: str) -> None:
nonlocal written_content
written_content = content

with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same mock signature issue as noted earlier.

The capture_write function is missing the self parameter. This pattern repeats here and at Line 1358.

🔧 Suggested fix
-        def capture_write(content: str) -> None:
+        def capture_write(self: Path, content: str) -> None:
             nonlocal written_content
             written_content = content

Apply the same fix at Line 1358.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
written_content = None
def capture_write(content: str) -> None:
nonlocal written_content
written_content = content
with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
written_content = None
def capture_write(self: Path, content: str) -> None:
nonlocal written_content
written_content = content
with patch.object(Path, "write_text", side_effect=capture_write):
controller._write_state_file(sample_processed_data, sample_args)
🤖 Prompt for AI Agents
In `@src/tests/test_display_controller.py` around lines 1328 - 1335, The test's
capture_write mock used for Path.write_text has the wrong signature: the
function capture_write defined for the patch of Path.write_text is missing the
first positional parameter that the real method receives (self); update the
capture_write used around controller._write_state_file to accept (self, content:
str) -> None so the patched call matches Path.write_text, and apply the
identical change to the other capture_write defined at the later occurrence (the
one mentioned around Line 1358) so both mocks mirror Path.write_text's
signature.

mossglenn and others added 2 commits February 6, 2026 20:07
Code changes:
- Fix timezone inconsistency: use UTC for lastUpdate timestamp to match reset.timestamp

Documentation changes:
- Fix documentation: correct default timezone from America/New_York to Europe/Warsaw
- Fix documentation: clarify reset_time is from processed_data, not --reset-hour setting

Test updates:
- Update test_last_update_is_recent to use UTC timestamps matching new implementation
- Note: CodeRabbit's suggestion to add 'self' parameter to mock functions was incorrect
  and has not been applied (patch.object side_effect doesn't receive self)

Resolves CodeRabbit review feedback on PR Maciek-roboblog#184

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Code improvements (display_controller.py):
- Remove redundant imports (json, os, timedelta, Path already at module level)
- Add defensive environment variable handling with fallback path

Documentation improvements (STATE_FILE.md):
- Add language specifier to code block for better markdown rendering
- Clarify file write behavior (not truly atomic, but typically complete)

Test improvements (test_write_state_file.py):
- Fix no-op string replacement (.replace("+00:00", "+00:00"))
- Replace misleading test with pytest.skip (behavior tested elsewhere)
- Rename test_state_file_written_atomically → test_state_file_written_completely
- Fix misleading docstring about percentage calculation
- Use more robust decimal verification (value == round(value, 2))
- Mock time instead of sleep for faster, more deterministic tests
- Tighten timezone assertion (January = UTC+1, no DST ambiguity)
- Add missing logger.error assertion

Result: Cleaner code, more accurate documentation, more robust tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@src/tests/test_write_state_file.py`:
- Around line 1206-1223: The test
test_graceful_failure_when_processed_data_malformed currently swallows
exceptions with try/except and has no assertions; remove the try/except so the
call to DisplayController._write_state_file runs normally and assert observable
behavior instead — for example, assert that no state file was created under
temp_report_dir after calling controller._write_state_file(malformed_data,
sample_args) and/or patch the controller's logger (or processLogger) and assert
logger.error was called; reference DisplayController._write_state_file,
test_graceful_failure_when_processed_data_malformed, sample_args, and
temp_report_dir when making these changes.
- Around line 1112-1125: The test test_graceful_failure_when_env_var_not_set
currently swallows all exceptions with a try/except: pass which negates its
assertion; update the test to explicitly assert no exception by either removing
the try/except so any exception fails the test or catching Exception and calling
pytest.fail with the exception message; target the call to
controller._write_state_file(sample_processed_data, sample_args) (and keep the
patch.dict(os.environ, {}, clear=True) setup) so failures surface instead of
being silently ignored.
- Around line 1072-1087: The test
test_graceful_failure_when_directory_doesnt_exist currently swallows any
exception with a try/except, making it vacuous; remove the try/except and simply
invoke controller._write_state_file(sample_processed_data, sample_args) inside
the patched environment (with patch.dict(os.environ,
{"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir})) so the test will fail if an
exception propagates (mirroring the pattern used in
test_no_exception_raised_on_write_failure).
- Around line 1089-1110: The test test_graceful_failure_on_permission_errors
currently wraps controller._write_state_file(...) in a try/except which swallows
any exception and makes the test always pass; remove the try/except and call
controller._write_state_file(sample_processed_data, sample_args) directly (with
the patch.dict setting CLAUDE_MONITOR_REPORT_DIR to str(temp_report_dir)) so any
permission-related exception will fail the test, keeping the subsequent
temp_report_dir.chmod(0o755) restore for cleanup.

In `@STATE_FILE.md`:
- Around line 424-425: The doc claims "atomic writes" but the implementation
uses Path.write_text(), which is not atomic; update the wording in the sentence
referencing "atomic writes" to accurately state that writes are non-atomic (or
may be mid-write) and that readers could observe partial content, and adjust the
adjacent guidance to recommend retrying reads after a brief delay (10–50ms) or
using a truly atomic write method (e.g., write to temp file + rename) — locate
and edit the text mentioning "atomic writes" and the referenced
Path.write_text() to keep the description consistent.
- Line 41: Update the "lastUpdate" example value in STATE_FILE.md to include the
timezone offset shown by datetime.now(timezone.utc).isoformat(); replace the
naive timestamp example "2026-01-09T13:30:22.976757" with an ISO8601 string that
includes the +00:00 offset (matching the output of
datetime.now(timezone.utc).isoformat()), so consumers are not misled into
treating it as a naive timestamp.
- Around line 95-97: The example for the `lastUpdate` field in STATE_FILE.md is
missing the UTC offset; update the `lastUpdate` example value to include the
timezone offset (e.g., append "+00:00") so it matches ISO 8601 with offset and
is consistent with the other examples that include `+00:00` for UTC; edit the
`lastUpdate` example string in the markdown to include the "+00:00" suffix.
🧹 Nitpick comments (4)
src/claude_monitor/ui/display_controller.py (2)

390-396: Missing mkdir — write fails silently if the report directory doesn't exist.

If a user sets CLAUDE_MONITOR_REPORT_DIR to a new path, or the default ~/.claude-monitor/reports hasn't been created yet, write_text will raise FileNotFoundError and the feature silently does nothing. Adding a mkdir call makes the feature self-contained.

📝 Suggested fix
             report_dir = os.environ.get(
                 "CLAUDE_MONITOR_REPORT_DIR",
                 str(Path.home() / ".claude-monitor" / "reports")
             )
             state_file = Path(report_dir) / 'current.json'
+            state_file.parent.mkdir(parents=True, exist_ok=True)
             state_file.write_text(json.dumps(state, indent=2))

343-346: secondsRemaining can be negative when reset_time is in the past.

If the session reset has already occurred but the monitor hasn't refreshed yet, reset_time - current_time produces a negative value. Consumers building countdown timers may not expect negative seconds. Consider clamping to max(0, ...).

📝 Suggested fix
-            reset_seconds = int((reset_time - current_time).total_seconds())
+            reset_seconds = max(0, int((reset_time - current_time).total_seconds()))
src/tests/test_write_state_file.py (2)

1127-1154: This test is fragile: chmod(0o444) doesn't restrict root, and try/except weakens the assertion.

In CI environments running as root, the permission change won't trigger a write error, so logger.error won't be called and the assertion will fail. Consider using patch.object on Path.write_text to raise PermissionError instead for a deterministic test. Also remove the try/except wrapper.

📝 Suggested fix
     def test_logger_called_on_errors(
         self,
         controller: DisplayController,
         temp_report_dir: Path,
         sample_processed_data: Dict[str, Any],
         sample_args: Mock,
     ) -> None:
         """Test logger is called on errors."""
-        # Make directory read-only to trigger error
-        temp_report_dir.chmod(0o444)
-
         with patch.dict(
             os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
         ):
-            with patch("logging.getLogger") as mock_logger:
-                mock_log_instance = Mock()
-                mock_logger.return_value = mock_log_instance
-
-                try:
-                    controller._write_state_file(sample_processed_data, sample_args)
-                except Exception:
-                    pass
-
-                # Verify logger.error was called
-                mock_log_instance.error.assert_called_once()
-
-        # Restore permissions
-        temp_report_dir.chmod(0o755)
+            with patch("pathlib.Path.write_text", side_effect=PermissionError("mocked")):
+                with patch("logging.getLogger") as mock_logger:
+                    mock_log_instance = Mock()
+                    mock_logger.return_value = mock_log_instance
+
+                    controller._write_state_file(sample_processed_data, sample_args)
+
+                    mock_log_instance.error.assert_called_once()

282-303: Token percentage test doesn't exercise _write_state_file's own calculation.

tokens_percent in the state file comes from processed_data["usage_percentage"], not from tokens_used / token_limit. This test sets both tokens_used=15000 and token_limit=50000, then asserts (15000/50000)*100, but the actual written value is whatever usage_percentage is (which is 30.0 from the fixture). The test passes coincidentally because the fixture's usage_percentage equals the expected calculation. Renaming or adjusting the test to make clear it validates the pass-through of usage_percentage would be more accurate.

Comment on lines +1072 to +1087
def test_graceful_failure_when_directory_doesnt_exist(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when directory doesn't exist."""
nonexistent_dir = "/nonexistent/directory/that/should/not/exist"

with patch.dict(os.environ, {"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir}):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
# Should handle gracefully
pass
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

try/except makes this test vacuous — it passes even if the method raises.

The intent is to verify no exception propagates, but wrapping in try/except: pass means the test always passes. Simply call the method without the try/except, like test_no_exception_raised_on_write_failure (Line 1156) does correctly.

📝 Suggested fix
     def test_graceful_failure_when_directory_doesnt_exist(
         self,
         controller: DisplayController,
         sample_processed_data: Dict[str, Any],
         sample_args: Mock,
     ) -> None:
         """Test graceful failure when directory doesn't exist."""
         nonexistent_dir = "/nonexistent/directory/that/should/not/exist"
 
         with patch.dict(os.environ, {"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir}):
-            # Should not raise exception
-            try:
-                controller._write_state_file(sample_processed_data, sample_args)
-            except Exception:
-                # Should handle gracefully
-                pass
+            # Should not raise exception - fail silently
+            controller._write_state_file(sample_processed_data, sample_args)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_graceful_failure_when_directory_doesnt_exist(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when directory doesn't exist."""
nonexistent_dir = "/nonexistent/directory/that/should/not/exist"
with patch.dict(os.environ, {"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir}):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
# Should handle gracefully
pass
def test_graceful_failure_when_directory_doesnt_exist(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when directory doesn't exist."""
nonexistent_dir = "/nonexistent/directory/that/should/not/exist"
with patch.dict(os.environ, {"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir}):
# Should not raise exception - fail silently
controller._write_state_file(sample_processed_data, sample_args)
🤖 Prompt for AI Agents
In `@src/tests/test_write_state_file.py` around lines 1072 - 1087, The test
test_graceful_failure_when_directory_doesnt_exist currently swallows any
exception with a try/except, making it vacuous; remove the try/except and simply
invoke controller._write_state_file(sample_processed_data, sample_args) inside
the patched environment (with patch.dict(os.environ,
{"CLAUDE_MONITOR_REPORT_DIR": nonexistent_dir})) so the test will fail if an
exception propagates (mirroring the pattern used in
test_no_exception_raised_on_write_failure).

Comment on lines +1089 to +1110
def test_graceful_failure_on_permission_errors(
self,
controller: DisplayController,
temp_report_dir: Path,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure on permission errors."""
# Make directory read-only
temp_report_dir.chmod(0o444)

with patch.dict(
os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
pass

# Restore permissions for cleanup
temp_report_dir.chmod(0o755)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same try/except issue as above — test always passes regardless of behavior.

📝 Suggested fix
     def test_graceful_failure_on_permission_errors(
         self,
         controller: DisplayController,
         temp_report_dir: Path,
         sample_processed_data: Dict[str, Any],
         sample_args: Mock,
     ) -> None:
         """Test graceful failure on permission errors."""
-        # Make directory read-only
         temp_report_dir.chmod(0o444)
 
         with patch.dict(
             os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
         ):
-            # Should not raise exception
-            try:
-                controller._write_state_file(sample_processed_data, sample_args)
-            except Exception:
-                pass
+            controller._write_state_file(sample_processed_data, sample_args)
 
-        # Restore permissions for cleanup
         temp_report_dir.chmod(0o755)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_graceful_failure_on_permission_errors(
self,
controller: DisplayController,
temp_report_dir: Path,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure on permission errors."""
# Make directory read-only
temp_report_dir.chmod(0o444)
with patch.dict(
os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
pass
# Restore permissions for cleanup
temp_report_dir.chmod(0o755)
def test_graceful_failure_on_permission_errors(
self,
controller: DisplayController,
temp_report_dir: Path,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure on permission errors."""
temp_report_dir.chmod(0o444)
with patch.dict(
os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
):
controller._write_state_file(sample_processed_data, sample_args)
temp_report_dir.chmod(0o755)
🤖 Prompt for AI Agents
In `@src/tests/test_write_state_file.py` around lines 1089 - 1110, The test
test_graceful_failure_on_permission_errors currently wraps
controller._write_state_file(...) in a try/except which swallows any exception
and makes the test always pass; remove the try/except and call
controller._write_state_file(sample_processed_data, sample_args) directly (with
the patch.dict setting CLAUDE_MONITOR_REPORT_DIR to str(temp_report_dir)) so any
permission-related exception will fail the test, keeping the subsequent
temp_report_dir.chmod(0o755) restore for cleanup.

Comment on lines +1112 to +1125
def test_graceful_failure_when_env_var_not_set(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when environment variable not set."""
# Remove the environment variable
with patch.dict(os.environ, {}, clear=True):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
pass
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same pattern — try/except: pass negates the test assertion.

📝 Suggested fix
         with patch.dict(os.environ, {}, clear=True):
-            # Should not raise exception
-            try:
-                controller._write_state_file(sample_processed_data, sample_args)
-            except Exception:
-                pass
+            controller._write_state_file(sample_processed_data, sample_args)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_graceful_failure_when_env_var_not_set(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when environment variable not set."""
# Remove the environment variable
with patch.dict(os.environ, {}, clear=True):
# Should not raise exception
try:
controller._write_state_file(sample_processed_data, sample_args)
except Exception:
pass
def test_graceful_failure_when_env_var_not_set(
self,
controller: DisplayController,
sample_processed_data: Dict[str, Any],
sample_args: Mock,
) -> None:
"""Test graceful failure when environment variable not set."""
# Remove the environment variable
with patch.dict(os.environ, {}, clear=True):
controller._write_state_file(sample_processed_data, sample_args)
🤖 Prompt for AI Agents
In `@src/tests/test_write_state_file.py` around lines 1112 - 1125, The test
test_graceful_failure_when_env_var_not_set currently swallows all exceptions
with a try/except: pass which negates its assertion; update the test to
explicitly assert no exception by either removing the try/except so any
exception fails the test or catching Exception and calling pytest.fail with the
exception message; target the call to
controller._write_state_file(sample_processed_data, sample_args) (and keep the
patch.dict(os.environ, {}, clear=True) setup) so failures surface instead of
being silently ignored.

Comment on lines +1206 to +1223
def test_graceful_failure_when_processed_data_malformed(
self,
controller: DisplayController,
temp_report_dir: Path,
sample_args: Mock,
) -> None:
"""Test graceful failure when processed_data is malformed."""
# Missing required fields
malformed_data = {"tokens_used": 100}

with patch.dict(
os.environ, {"CLAUDE_MONITOR_REPORT_DIR": str(temp_report_dir)}
):
# Should handle gracefully
try:
controller._write_state_file(malformed_data, sample_args)
except Exception:
pass # Expected to fail, but shouldn't crash the app
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same try/except: pass issue — and the test doesn't assert anything.

This test should verify that the method doesn't raise (remove the try/except) and ideally assert on some observable behavior (e.g., file not created, or logger called).

🤖 Prompt for AI Agents
In `@src/tests/test_write_state_file.py` around lines 1206 - 1223, The test
test_graceful_failure_when_processed_data_malformed currently swallows
exceptions with try/except and has no assertions; remove the try/except so the
call to DisplayController._write_state_file runs normally and assert observable
behavior instead — for example, assert that no state file was created under
temp_report_dir after calling controller._write_state_file(malformed_data,
sample_args) and/or patch the controller's logger (or processLogger) and assert
logger.error was called; reference DisplayController._write_state_file,
test_graceful_failure_when_processed_data_malformed, sample_args, and
temp_report_dir when making these changes.

"tokens": 32859.04,
"messages": 0
},
"lastUpdate": "2026-01-09T13:30:22.976757"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

lastUpdate example is missing timezone offset.

The code uses datetime.now(timezone.utc).isoformat() (display_controller.py Line 387), which produces a string with +00:00 suffix. The example here omits it, which could mislead consumers into treating this as a naive timestamp.

📝 Suggested fix
-  "lastUpdate": "2026-01-09T13:30:22.976757"
+  "lastUpdate": "2026-01-09T13:30:22.976757+00:00"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"lastUpdate": "2026-01-09T13:30:22.976757"
"lastUpdate": "2026-01-09T13:30:22.976757+00:00"
🤖 Prompt for AI Agents
In `@STATE_FILE.md` at line 41, Update the "lastUpdate" example value in
STATE_FILE.md to include the timezone offset shown by
datetime.now(timezone.utc).isoformat(); replace the naive timestamp example
"2026-01-09T13:30:22.976757" with an ISO8601 string that includes the +00:00
offset (matching the output of datetime.now(timezone.utc).isoformat()), so
consumers are not misled into treating it as a naive timestamp.

Comment on lines +95 to +97
- **`lastUpdate`** (string): ISO 8601 timestamp when this file was last written
- Example: `"2026-01-09T13:30:22.976757"`
- Useful for detecting stale data
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same issue: lastUpdate example lacks +00:00.

Consistent with the fix above, update this example too.

📝 Suggested fix
-- **`lastUpdate`** (string): ISO 8601 timestamp when this file was last written
-  - Example: `"2026-01-09T13:30:22.976757"`
+- **`lastUpdate`** (string): ISO 8601 UTC timestamp when this file was last written
+  - Example: `"2026-01-09T13:30:22.976757+00:00"`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **`lastUpdate`** (string): ISO 8601 timestamp when this file was last written
- Example: `"2026-01-09T13:30:22.976757"`
- Useful for detecting stale data
- **`lastUpdate`** (string): ISO 8601 UTC timestamp when this file was last written
- Example: `"2026-01-09T13:30:22.976757+00:00"`
- Useful for detecting stale data
🤖 Prompt for AI Agents
In `@STATE_FILE.md` around lines 95 - 97, The example for the `lastUpdate` field
in STATE_FILE.md is missing the UTC offset; update the `lastUpdate` example
value to include the timezone offset (e.g., append "+00:00") so it matches ISO
8601 with offset and is consistent with the other examples that include `+00:00`
for UTC; edit the `lastUpdate` example string in the markdown to include the
"+00:00" suffix.

Comment on lines +424 to +425
- File may be mid-write when read (very rare with atomic writes)
- Retry read after brief delay (10-50ms)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"Atomic writes" claim is inaccurate — the implementation uses Path.write_text().

Line 388 correctly states that Path.write_text() is used, but this section refers to "atomic writes." Path.write_text() is not atomic — a reader could observe a partial file if it reads during a write. Consider changing the wording to be consistent with Line 388–389.

📝 Suggested fix
-- File may be mid-write when read (very rare with atomic writes)
-- Retry read after brief delay (10-50ms)
+- File may be mid-write when read (rare under normal conditions, but possible since writes are not atomic)
+- Retry read after brief delay (10-50ms)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- File may be mid-write when read (very rare with atomic writes)
- Retry read after brief delay (10-50ms)
- File may be mid-write when read (rare under normal conditions, but possible since writes are not atomic)
- Retry read after brief delay (10-50ms)
🤖 Prompt for AI Agents
In `@STATE_FILE.md` around lines 424 - 425, The doc claims "atomic writes" but the
implementation uses Path.write_text(), which is not atomic; update the wording
in the sentence referencing "atomic writes" to accurately state that writes are
non-atomic (or may be mid-write) and that readers could observe partial content,
and adjust the adjacent guidance to recommend retrying reads after a brief delay
(10–50ms) or using a truly atomic write method (e.g., write to temp file +
rename) — locate and edit the text mentioning "atomic writes" and the referenced
Path.write_text() to keep the description consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant