usage-monitor-for-claude
jens-duttke/usage-monitor-for-claude/.claude/CLAUDE.md
Apply Python best practices and clean code principles. Only change code relevant to the prompt. Prioritize readability and auditability - users handle credentials and must be able to verify the code is safe at a glance.
CLAUDE.md301 starsChanged 9 days ago
- Reads credentials
# Project Guidelines
Apply Python best practices and clean code principles. Only change code relevant to the prompt.
Prioritize readability and auditability - users handle credentials and must be able to verify the code is safe at a glance.
## Platform
- Windows and Linux are both supported. macOS is not - a community fork covers it
- **No module outside `usage_monitor_for_claude/platforms/` may import `ctypes.windll`, `winreg`, `msvcrt`, `fcntl`, `gi`, or use `subprocess.CREATE_NO_WINDOW`.** Everything OS-specific lives behind the platform layer; the rest of the package must import cleanly on both systems
- `platforms/__init__.py` dispatches on `sys.platform` and re-exports one API from `win32.py` or `linux.py`. That dispatch is the *only* place a platform check belongs
- Three concerns are big enough to get their own file pair plus a dispatch module, because they pull in dependencies the platform package must stay free of (`pystray`, `pywebview`) or would close an import cycle: `instance.py` (`instance_win32.py`/`instance_linux.py`) and `popup.py` (`popup_win32.py`/`popup_linux.py`)
- `instance.py` must not be imported from `platforms/__init__.py`: the guard needs `i18n`, which imports `settings`, which imports the platform package
- **`platforms/linux.py` must import without any GUI toolkit present.** `gi` is imported inside the functions that need it, and every helper degrades to a documented fallback when the session bus or GTK is unavailable. The Linux virtual environment needs `--system-site-packages` to run the app at all, so it *does* see PyGObject and cannot prove this by accident - `TestImportsWithoutPyGObject` stages the absence in a subprocess instead. Keep it a subprocess: a mock would only prove the mock
- A Linux capability that has no counterpart (notification identity, tray double-click, DPI awareness) is an explicit no-op or returns `False` with a docstring saying why - never a silent omission
- `prepare_gui_environment()` defaults `GDK_BACKEND` to `x11` on Linux and runs before pywebview is imported. Wayland refuses client-side window placement, so the popup could not reach its anchor; `setdefault` leaves an explicit choice alone
- Whether the tray menu offers autostart comes from `autostart_supported()`, not from `sys.frozen`. Windows needs a packaged build (a Run value holding only the interpreter path starts Python, not the app); Linux writes both the interpreter and `Path=` into the `.desktop` entry, so a source checkout starts just as reliably
- `setup_console()` (Windows) must leave a standard stream the caller already redirected alone. `CONOUT$` names the console device, so opening it bypasses `> log.txt` and `-RedirectStandardOutput` alike and hands a bug reporter an empty file - the console is for a stream that has no redirection of its own, and nothing else. `GetStdHandle` needs an explicit `restype`, because ctypes' default `c_int` truncates a 64-bit HANDLE. The unit tests mock `ctypes` and therefore prove none of this: verify a change here against a real GUI-subsystem process (`pythonw.exe` behaves like the frozen build), never against the tests alone
- The `usage-monitor-for-claude` shell script in the repository root is the Linux launcher: it resolves its own symlink (so a link in `~/.local/bin` finds the checkout, not the caller's directory) and puts that checkout on `PYTHONPATH` instead of `cd`-ing into it, so the app inherits the working directory it was started from. It is deliberately *not* what the autostart entry runs - `Exec=` keeps interpreter + module + `Path=`, which also covers a frozen build and the `--config-dir` of a secondary instance. `tests/test_launcher.py` runs it against a stub interpreter in a temporary checkout
## Popup Window & DPI (Windows host)
- Everything in this section describes `platforms/popup_win32.py`. `popup.py` itself owns only the data flow
- The popup uses pywebview with a WinForms host window and Edge WebView2
- pywebview 6.x `resize()` **and** `move()` both expect **logical pixels** (pywebview applies DPI scaling internally for both)
- `_tray_position()` still receives physical pixel dimensions (needed to calculate position against Win32 physical coordinates) and returns **logical coordinates** for `move()` - never change this to physical
- `_tray_position()` uses `Shell_TrayWnd` + `MonitorFromWindow` + `GetMonitorInfoW` to find the monitor that owns the taskbar, then compares `work.left > mon.left` (not `> 0`) to detect a left-side taskbar - this correctly handles multi-monitor layouts where the primary monitor is not at virtual x=0
- Never replace `resize()`/`move()` with direct `SetWindowPos` calls for tray-anchored positioning - pywebview's internal scaling means raw Win32 calls would fight with pywebview's coordinate handling. The one exception is the pinned-popup drag (next bullet)
- The pinned-popup drag (`_begin_drag`/`_drag`/`_end_drag`) deliberately uses raw `SetWindowPos` with **physical** cursor coordinates (`GetCursorPos` minus the grab offset captured on mouse-down), not pywebview's `move()`. Reason: `move()` and JS `screenX` deltas are scaled by a single monitor's DPI, which jumps at a monitor boundary and makes the cursor drift off the window and the size break. After a drag that crosses a DPI boundary, `_end_drag` re-asserts the size once via `resize()` against the destination monitor's DPI. Do not collapse this back to `move()` - it reintroduces the mixed-DPI drift
- The taskbar icon is hidden via Win32 extended styles (`WS_EX_TOOLWINDOW` + remove `WS_EX_APPWINDOW`). Do **not** use WinForms `ShowInTaskbar = False` - it recreates the native window handle, which crashes WebView2 from background threads
## Popup Window (Linux host)
- `platforms/popup_linux.py`. Four properties of pywebview's GTK backend shape it, and **each one is silent when violated** - every pywebview call still reports success:
- `Window.show()` does **not** map a window created with `hidden=True`. The GTK window must be mapped with `show_all()` + `present()`. `window.x`/`window.width` still report the requested values, so only the X server tells the truth (`map_state`)
- A `move()` on an unmapped window is **discarded**, and the compositor then places the window by its own policy. The move must follow the map
- Without `GdkWindow.focus()` the compositor's focus-stealing prevention leaves a frameless keep-above window unfocused, so `focus-out-event` never fires and the popup could never dismiss itself
- **No GTK signal handler may call a blocking pywebview API.** Handlers run on the main loop, and pywebview waits for that same loop - the whole session deadlocks. `_on_loaded` therefore hands off to a worker thread
- `resizable=True` is mandatory: GTK ignores `resize()` on a non-resizable window, so the content-driven height would never apply. WebKitGTK also needs a real `file://` URI - handed a bare path it stays on `about:blank` and the injected `init()` fails with a ReferenceError. Both live in the host's `WINDOW_KWARGS` and `popup_url()`
- Dismissal is `focus-out-event` plus `key-press-event`, not global hooks - Wayland has no equivalent of `WH_MOUSE_LL`, and none is needed once the window holds focus
- There is no DPI arithmetic: GTK reports and consumes logical pixels on both sides
- StatusNotifierItem never reports where the panel drew the icon - no protocol carries that - so the popup anchors to the work-area corner instead of the icon
## Tray Icon Interaction (Windows)
- pystray has no native double-click support (it fires the default menu item on every `WM_LBUTTONUP`). Double-click is added only when a quick action is configured: `_install_double_click_handler()` swaps the `WM_NOTIFY` entry in pystray's private `_message_handlers` table (matched by identity against `icon._on_notify`) for `_on_tray_message`. This reaches into pystray internals - if a pystray upgrade renames `_message_handlers`/`_on_notify`, this is where it breaks
- With a command configured, the single click (popup) is deferred by `GetDoubleClickTime()` via a `threading.Timer` and cancelled when the second click arrives; the trailing `WM_LBUTTONUP` that always follows a `WM_LBUTTONDBLCLK` is swallowed via `_swallow_next_up`. All tray-message state is guarded by `_click_lock`, and `_fire_single_click()` re-checks the timer under the lock so a double-click landing exactly as the timer fires still suppresses the popup
- When no quick action is configured, the handler is **not** installed - pystray's instant single-click popup must stay untouched (no double-click delay). Do not make the deferral unconditional
- `WM_NOTIFY` and other message handlers (right-click menu) must still fall through to the saved `_pystray_on_notify`
- The whole mechanism lives in `platforms/win32.install_tray_click_handler()`, which reports whether the swap succeeded. Linux returns `False`: a StatusNotifierItem is drawn and driven by the panel, `HAS_DEFAULT_ACTION` is `False`, and no button event ever reaches the process. `app.py` records the result in `double_click_installed` rather than assuming it worked
- A configured command must never be unreachable. Where the swap did not happen, the tray menu shows a **Run Quick Action** entry instead, kept in sync by `_quick_action_menu_visible()` - pystray resolves that predicate when the menu opens, which is after the install attempt. It also covers a Windows build where a pystray upgrade broke the swap, so that failure degrades into a menu entry rather than a dead setting
## Event Commands
- Event commands run fire-and-forget with output discarded (`run_event_command` in `command.py`). User-driven actions - the "Test event commands" menu handlers and the quick action - pass `capture_output=True`, which captures stdout/stderr, prints them, and raises an error message box when the command exits non-zero, so a wrong path is not swallowed silently. Automatic events (`on_reset_command`, `on_threshold_command`, `on_startup_command`) must stay silent (no `capture_output`) - a background event must never pop a dialog. A new event command belongs on whichever side matches: user-driven surfaces failures, automatic stays silent
- The quick action (`quick_action_command`, formerly `on_double_click_command` and still readable under that name via `settings._quick_action_command()`) additionally passes `report_late_failures=False`, which limits the error box to a non-zero exit within `_STARTUP_FAILURE_WINDOW` seconds of the launch. The box exists to catch a broken configuration (wrong path, bad arguments), and that shows up immediately; this command typically launches an app the user keeps open, whose own exit code minutes or hours later says nothing about the configuration - it crashed, was killed from the Task Manager, or a second instance of itself replaced it. A late failure is still printed, just not shown. The "Test event commands" menu keeps the default (`True`): there the exit code is the whole point of running the command, so a long-running command's failure must still surface
- `capture_output` waits for the command on a daemon thread, so the caller (a tray/menu/poll thread) is never blocked, even when the command launches a long-running app
- Every value in the `env_vars` handed to `run_event_command` must be a `str`. `subprocess.Popen` rejects a `None` in `env` with a `TypeError`, and `run_event_command` catches that and only prints the traceback - in the windowless EXE the command then silently never starts, with no dialog and no crash. API fields that can be `null` (`resets_at` for a quota without an active window, the normal state right after a reset) go through `entry.get('resets_at') or ''`, never `entry.get('resets_at', '')` - the default applies only to a missing key, not to a `null` value
## Claude CLI
- The `cli_command` setting (name -> base command, e.g. a WSL install) is **display only**: `find_installations()` lists each entry *in addition to* the auto-detected native CLI and the IDE extensions, all of which stay exactly as they were. It must never gain a second job - not the token refresh, not the API User-Agent, not authentication of any kind
- Reason it must stay out of the refresh: `refresh_token()` works only as a side effect - `claude update` makes the CLI renew the expired token *in its own credentials file*, and `cache._try_token_refresh()` then re-reads `CLAUDE_CREDENTIALS` and gives up when the token is unchanged. A WSL CLI keeps its credentials inside WSL (`/home/<user>/.claude/`), so routing the refresh through it would renew a token this app never reads: the Windows file stays untouched, the unchanged-token check always trips, the refresh can never succeed - and the user's WSL install gets updated unasked from a poll thread. The native CLI is also why it stays listed: it is the install the app actually authenticates with
- `cli_version()` caches per binary **mtime**, so an update is picked up automatically. A `cli_command` has no local file to stat (`wsl ...` cannot be mapped to a Windows path reliably), so `_command_version()` caches per command tuple for the **process lifetime**: updating that CLI shows up after an app restart. Do not re-probe per read - the popup's `_update_loop` calls `find_installations()` on every data change, which would boot WSL every few minutes. Never cache a failed run (timeout/OSError/lost stream) - that would pin an empty version for the whole session
- Both version helpers run through `_parse_version()`. Never write an unparsed string into a version cache - it is rendered as a version in the popup
- Every subprocess call in `claude_cli.py` goes through `_run_cli()`, which pins `encoding='utf-8', errors='replace'`. The Claude CLI is a Node application and writes UTF-8 whatever the Windows code page is; letting `subprocess` decode with the ambient locale codec instead raises inside the reader thread it uses to drain the pipe, and the stream then comes back as `None` despite `capture_output=True`. The loss is per-stream, so an ASCII version number next to a non-ASCII npm warning leaves stdout intact and drops only stderr. Do not add a bare `subprocess.run` here, and do not extend this to `command.py` - that one runs arbitrary user commands, where a `.bat` emits the OEM code page and the ambient codec is the correct assumption
- The authentication messages (`auth_expired`, `auth_expired_short`, `no_token`, `warn_login`) name the **CLI** and end with the command `claude auth login` - never a bare "log in to Claude Code". The app authenticates with the credentials file the CLI writes, and a Claude Code that is visibly running does not necessarily keep that file current. "Open Claude Code to log in again" therefore reads as already done, and the user goes looking for a bug in the monitor instead - issue #89 is a full report written from that misreading. Translations keep the command verbatim
- `_run_cli()` raises `OSError` when a stream comes back `None` rather than substituting `''`. That is what routes a lost stream into each caller's existing error handling with no per-call-site branching: `refresh_token()` reports an error instead of concatenating `None`, and both version probes fall back to their `except` and return `''` **uncached**, so the next poll retries. Substituting `''` instead would cache the empty version and turn a transient failure into a sticky one
## Notifications
- Windows shows the process's app icon in the toast header. Without an explicit identity that icon is the live tray icon, which reflects the most-exhausted quota - so a "quota reset" or partial-usage toast would carry the exhausted `✕` glyph and contradict its own text. `notification_identity.register_notification_identity()` gives the process a fixed identity instead: it registers `HKCU\Software\Classes\AppUserModelId\<AUMID>` with `DisplayName` + `IconUri` and then calls `SetCurrentProcessExplicitAppUserModelID`, so every toast shows the neutral app logo regardless of the tray icon. No Start Menu shortcut is needed - the registry registration alone is enough for the legacy `Shell_NotifyIcon` toast
- `register_notification_identity()` runs once in `__main__.py`, right after the single-instance check and **before any window is created** (the AppUserModelID must be set before the process presents UI). It re-registers on every startup because a frozen build extracts the logo to a fresh `sys._MEIPASS` directory each run, changing its path
- The logo is a **multi-size `.ico`** (`notification_logo.ico`, 16-256 px), never a single PNG: Windows renders the toast header icon small and downscales a single large image so badly that the "C" looks jagged; a multi-size `.ico` lets Windows pick a crisp dedicated frame (the same reason the tray/EXE icon stays sharp). `IconUri` accepts `.ico`. The asset is derived from `usage_monitor_for_claude.ico` with the usage bars emptied (empty-bars = "capacity available"); it is a separate notification-only asset, so the tray/EXE icon is unchanged. It is bundled via the spec `datas`
- Registration is best-effort and must never block startup: a missing logo file or a registry write error makes it return early and keep the default identity (the tray icon) - better than an empty/placeholder icon. Setting the AUMID only *after* the registry write succeeds avoids Windows showing its generic 3-square placeholder (which is what an AUMID registered without a resolvable icon produces)
## Quota Fields
- Never hardcode API quota field names (e.g. `five_hour`, `seven_day_sonnet`) in display logic, alert handling, or reset detection - new fields must be auto-detected from the API response structure
- A quota field is any dict entry with a non-null `utilization`; `extra_usage` has a separate structure and is handled independently. Whether that quota *applies to the account* is `is_active_quota()`: a reset window, a name `parse_field_name()` can read, or the `from_account_limits` marker `_merge_scoped_limits()` sets on the fields it synthesises. A bar additionally needs the `resets_at` key itself - that is what keeps the differently shaped `extra_usage` block out of `expand_popup_fields()`
- The name clause in `is_active_quota()` is load-bearing and must not be simplified away. The API lists quota types before they apply, under code names (`nimbus_quill`, `tangelo`), carrying a utilization of 0 and no reset window - structurally identical to a real quota sitting between a reset and its next use, so nothing but the name separates them. Drop the clause and `five_hour` disappears from the popup after every reset, until the next token is spent. It holds only while Anthropic keeps naming periods `<number word>_<unit>`: a `monthly` or `daily` field would not parse and would blink out in that window. Growing `_NUMBER_WORDS` does not help, because those are not number words - the marker covers it for model-scoped limits, and a top-level field with such a name is the trigger to revisit this
- Quota fields can be `null` in the API response (e.g. when a quota type is not enabled for the account) - always use `(data.get('key') or {})` instead of `data.get('key', {})` when chaining `.get()` calls, because the latter returns `None` when the key exists with a `null` value
- `or {}` guards a `null`, not a type change. A key that arrives as a list or a string is truthy and passes straight into the next `.get()`, raising `AttributeError`. Inside a `fetch_*` function its own `except` catches that; anywhere the result reaches `Cache.update()` unguarded it escapes `poll_loop()` and raises a crash box, so a shape change in one undocumented field stops all polling for the session. Not hypothetical: a reader of `spend.balance` chained `or {}` through two levels and would have ended the poll loop on a `spend` arriving as a list - it was removed before it shipped. Chain with `or {}` where the response shape is settled, and check `isinstance` where a new or undocumented block is being read
- Labels, periods, and sort order are derived from the field name via `parse_field_name()` - no per-field mapping tables
- Model-scoped limits (e.g. a weekly Fable limit) arrive only inside the `limits` array via `scope.model`, not as top-level fields - `_merge_scoped_limits()` in `api.py` normalizes each into a synthetic top-level field (e.g. `seven_day_fable`) so all of the above applies unchanged. The period prefix is derived from the same-`group` non-scoped limit's shared `resets_at` (never hardcoded); an existing top-level field is never overwritten, and inactive scoped limits (no `resets_at`) are still surfaced at 0% - the `from_account_limits` marker is what keeps those visible when the inherited prefix is not a name `parse_field_name()` can read
- Locale files use template keys (`session_label`, `weekly_label`, `notify_threshold_generic`) - never add per-field translation keys
## Polling & Reset Alignment
- `cache.update()` enforces a hard `POLL_FAST` cooldown - no successful fetch happens more often than every `POLL_FAST` seconds. All poll scheduling is built around this floor; `_align_to_reset()` never returns an interval below `POLL_FAST` (enforced by a test invariant)
- The one exception to that floor is `cache.update(force=True)`, used only after a confirmed account switch: the poll loop watches the credentials access token and, when it changes to a token whose account UUID differs (probed via `ensure_profile(bypass_rate_limit=True)`, `_account_switched()`), forces a single immediate fetch that bypasses both the cooldown and the 429 backoff. Safe because the newly selected account has no polling history and cannot be the source of either throttle; the old account's reset alignment is moot once its data is replaced, so the danger-window rule does not apply
- A switch can also land *while a fetch is in flight*, which pairs the previous account's usage with the new account's profile. Two rules keep that from being mistaken for a completed switch: the poll loop reads its `token_seen` baseline **before** `self.update()` (reading it after would swallow the change and skip the forced refetch entirely), and `update()` discards the identity comparison when `UpdateResult.token` - the token the successful fetch was sent with - no longer matches the credentials file. That guard must return **before** `ensure_profile()`, so the popup keeps showing the old account's name next to its own numbers instead of the new name next to stale ones. Every baseline (`_prev_account_uuid`, `_prev_utilization`, `_notified_thresholds`) stays untouched, so the forced refetch reports the switch together with the new account's data. Never let `_record_success()` infer that token by re-reading the credentials - it must be the one captured before the request
- On a 401, `_try_token_refresh()` retries with the current credentials token directly (skipping the slow `claude update` subprocess) whenever it already differs from the token that failed - the account-switch / out-of-band-refresh case - and only runs the CLI refresh when the token is unchanged. This keeps an account switch from stalling on a subprocess of up to 60s while the old (already revoked) token returns 401
- When a 401 leaves the token blocked (`_last_failed_token`) - e.g. the stored access token expired and `claude update` did not renew it - the poll-loop token watcher retries as soon as the credentials token changes, even for the same account (`self._last_response.get('auth_error')` branch, a non-forced update so cooldown/backoff still apply). A token refreshed out of band then recovers both usage and profile promptly instead of only at the next error-cadence poll or after a restart
- Invariant: no discretionary fetch may land in the "danger window" - the last `POLL_FAST - RESET_BUFFER` seconds before a quota reset. A fetch there consumes the cooldown and forces the reset-confirming poll to overshoot the reset. The reset-aligned cadence poll owns the post-reset confirmation
- The cadence scheduler (`_align_to_reset`) already never schedules a poll into the danger window. Discretionary fetches must defer to it when a reset is within `POLL_FAST`: the popup skips its background refresh (`_should_refresh_usage()`), and the away-return path realigns via `_safe_poll_target()` instead of polling immediately. Cold start (no data yet) is the only allowed exception
- `_safe_poll_target()` is the single guard for every target the poll loop computes outside `_calculate_poll_interval()` (the push-forward after a popup fetch, the away-return pull-back). It moves a candidate onto the reset-aligned slot when it would land in the danger window or past that slot. Never open-code either check again
- The poll-loop push-forward (which avoids a redundant fetch right after a popup fetch) reacts only to an actual new fetch (`last_success_time` advanced) and never moves a poll past a reset-aligned slot
- While the user is away polling **slows down** (`IDLE_INTERVAL`, 15 min) instead of stopping. The loop must never block: the account-switch token watcher, the clock-jump re-anchor and the reset alignment all live in the same one-second wait loop, and a paused loop meant an account switch, a quota reset and every event command waited for the user to come back. `_calculate_poll_interval()` applies the away floor to the *base* cadence and only then aligns to the reset, so a reset on a locked machine is still confirmed within `RESET_BUFFER` seconds. The one accepted stretch: with a reset between `IDLE_INTERVAL` and `IDLE_INTERVAL * 1.5` away, alignment commits the poll to the reset instead of an earlier slot - capping it there would drop that poll into the danger window
- `_polling_throttled()` - not `_is_user_away()` - decides that cadence. An open popup holds the normal cadence however long ago the last input was, because its numbers are on screen; `_screen_hidden()` (lock screen or screensaver) overrides that, since nobody reads a popup behind it. `_is_user_away()` keeps its own meaning for deferring notifications, where an idle machine is reason enough to hold a toast back
- An unconfirmed reset (`_reset_overdue()`: a `resets_at` still in the past) suspends the away floor until the API reports the new window. This is derived from the response, not tracked in a flag - a quota that was at 0% before its reset produces no usage drop, so a "reset pending" flag cleared by a drop would never clear again
## Security & Transparency
- All URLs and API endpoints as top-level constants - no dynamic URL construction
- `API_URL_PREPAID_CREDITS` is the one endpoint carrying a path parameter, and it stays the only one: the prepaid balance is addressed per organization, so the uuid cannot be known at build time. It is a single constant with one `{org_uuid}` placeholder, and `fetch_prepaid_credits()` matches the value against `_ORG_UUID_PATTERN` before substituting it, abandoning the request when it does not match - so the substituted segment can only ever be hex digits and dashes, never a slash, a dot-dot or a scheme. The pattern is anchored `\A...\Z`, not `^...$`: `$` also matches before a trailing newline, and `tests/test_api.py` covers exactly that case. A future endpoint needing a path parameter follows this shape - one constant, one placeholder, validated before substitution - or it does not ship
- Network communication exclusively with `api.anthropic.com` - no other destinations (the one exception is the Windows chain engine's own traffic, see "TLS Verification")
- Credentials used only in HTTP Authorization headers - never log, store, or transmit elsewhere
- The app writes no files **on Windows**, where the only system state it changes is two `HKCU` registry values: the notification identity and the autostart entry (both in `platforms/win32.py`). On Linux the same two concerns need files: the autostart entry is an XDG `.desktop` file in `~/.config/autostart/`, and the single-instance guard holds a `0600` lock file in `$XDG_RUNTIME_DIR`. Nothing else is ever written
- Any new persistent write needs a matching update in `README.md` and `PRIVACY.md`, per platform. The list of what the app touches is part of the audit story and must never become inaccurate
- Security-critical code (credentials, API calls) isolated in `api.py` - the only module handling credentials. It is platform-neutral and must stay that way
- The effective Claude config directory comes from `instance_id.effective_config_dir()` in every module, `api.py` included - never re-derived inline from `CLAUDE_CONFIG_DIR`. The helper resolves the path, so an inline copy addresses the credentials through a different spelling than the one every other module derives and than the one `config_dir_suffix()` hashes into the instance identity. `instance_id` imports only the standard library, which is what makes it importable from `api.py`; the reverse direction stays forbidden, because `__main__.py` needs it before `CLAUDE_CONFIG_DIR` is settled. `tests/test_api.py::test_matches_instance_id_helper` pins this with a spelling that only compares equal once resolved
- `_redact_home()` in `verbose.py` compares the home directory in both its literal and its resolved spelling, because the paths reaching it differ: the credentials path arrives resolved, while `sys.executable`, `sys._MEIPASS` and the `CLAUDE_CONFIG_DIR` row (which `__main__.py` stores resolved) keep the spelling they were given. Where the home directory is reached through a symlink or a junction those two differ, and matching only one of them prints the username in full - in the dump users attach to public issues. Resolving can fail there (a symlink loop raises `RuntimeError` up to Python 3.12, an unreachable network path an `OSError`), so it falls back to the literal spelling rather than not printing at all
- No `eval()`, `exec()`, `compile()`, or dynamic imports - no dynamic code execution
- No obfuscation - no base64-encoded strings, no encoded URLs or tokens
- Modular package architecture in `usage_monitor_for_claude/` - small focused modules are easier to audit than one large file
- Pure data files (translations, config) stay separate - they contain no logic or credential access
- Minimal, well-known dependencies only (e.g., requests, Pillow, pystray)
## TLS Verification
- Server certificates are verified against the Windows certificate store through `truststore` (`truststore.inject_into_ssl()` at module level in `api.py`), not against the CA bundle shipped with `requests`. A corporate SSL-inspection proxy whose root certificate arrives via group policy is trusted like in the browser; with the bundle alone the app fails behind such a proxy, and that failure used to surface as a plain "could not connect"
- Deliberately always on, not a setting: whoever can add a root to the Windows store already controls the machine and can read the credentials file directly, so an opt-in would add a setting without adding protection. `PRIVACY.md` states this trust model - keep both in sync
- `inject_into_ssl()` replaces `ssl.SSLContext` process-wide (and urllib3's reference to it). That is safe only because `requests` is the sole TLS client in the process - WebView2 uses the Windows network stack and bottle serves the popup over plain localhost HTTP. It must run before the first request, which module level in `api.py` guarantees; re-running it (module reload in tests) is idempotent
- `requests` still loads certifi into the context via `load_verify_locations`, and `truststore` falls back to those CAs when the Windows engine rejects a chain - effectively the union of both stores. Do not remove certifi from the picture to "simplify"; it covers a public root Windows has not downloaded yet
- Verification is delegated to the Windows chain engine (`CertGetCertificateChain`, default engine, no revocation flags). Like for every Windows application, that engine may fetch a missing intermediate from the CA or a missing trusted root from Microsoft's root update while building the chain. `api.anthropic.com` sends its full chain, so in practice this only happens for a root not yet on the machine - it is Windows' own traffic, not a request the app makes
- `requests.exceptions.SSLError` is a subclass of `requests.ConnectionError`. The `except` for it in `fetch_usage()` must stay ahead of the `ConnectionError` handler, or certificate failures collapse into the generic connection message again (`test_certificate_error` guards this)
## Type Hints & Documentation
- Module docstring as very first element in file (title with equals underline, blank line, description)
- Always include `from __future__ import annotations` as first import (after module docstring)
- Type hints in function signatures only, not in docstrings
- numpydoc (NumPy-style) docstrings for all public functions, classes, and non-trivial methods
- Skip docstrings for trivial/self-explanatory methods (1-3 lines where the name fully describes the behavior)
- Never mention changes, improvements, or type hints in comments or docstrings
- `# type: ignore` only with specific error code and short reason: `# type: ignore[code] # reason`
## Formatting
- PEP8-based with extended line length of 140-160 characters (flexible for arg parsing when alignment improves readability)
- Function signatures and calls on one line when reasonable
- Never use deep indentation to align with previous line's opening bracket/parenthesis
- When breaking lines, use standard 4-space indentation from statement start
- Single quotes (`'`) default, double (`"`) when containing single quotes, triple-double (`"""`) for docstrings
- Use hyphens (`-`) for dashes in text, never em dashes (`—`) or en dashes (`–`)
## Spacing
- Two blank lines between top-level functions/classes, one between methods
- Blank lines separate logical blocks (after guards, before returns)
## Imports
- Three groups separated by blank lines: standard library, third-party, local
- Within groups: `import` before `from...import`, sorted alphabetically
- Relative imports within the `usage_monitor_for_claude` package (e.g. `from .api import ...`), except `__main__.py` which requires absolute imports for PyInstaller compatibility
- Absolute imports for external packages, avoid wildcards
## Structure
- Main exported functions first, then helpers in logical order
- In library modules: prefix non-exported helpers with underscore; in executable scripts: no underscore prefix (everything is internal)
- `__all__` for library modules; omit for executable scripts
## Style
- Prefer functional/modular code over classes
- Isolate side effects in dedicated modules (e.g. `api.py`, `command.py`) - keep helper and utility functions pure
- Descriptive, self-explanatory variable and parameter names, no global variables - no ambiguous names like `other`, `data2`, `flag`. Every name must be immediately clear without reading the surrounding code
- Comments only for complex/non-obvious code and math operations - never about improvements or changes
## List Comprehensions
- Avoid complex comprehensions with multiple conditions or long expressions
- Use explicit loops with guard clauses when: multiple conditions, repeated function calls per item, or unclear logic
## Validation & Errors
- Validate inputs at function start with assertions or exceptions
- Early returns and guard clauses
## PyInstaller / Build
- Spec file: `usage_monitor_for_claude.spec` - all build config lives there
- When adding new data files (translations, configs, assets): add them to the `datas` list in the spec file
- When adding new imports: check if PyInstaller detects them automatically; if not, add to `hiddenimports`
- Never exclude standard library modules that are transitive dependencies (e.g., `email` is needed by `urllib3`/`requests`)
- After any dependency change, verify the `excludes` list doesn't break transitive imports
## README
- Keep the feature list and descriptions in `README.md` in sync when adding, changing, or removing user-facing features
- When adding or removing a `locale/*.json` file, update the language count and the parenthesized list in the "N languages (...)" feature bullet to match the actual locale files - both the number and the names must stay in sync
- The feature list follows the user's decision journey - place new features in the appropriate tier:
1. **Getting started** (barrier to entry): Portable, Zero configuration
2. **Daily visible value** (what the user sees every day): Live tray icon, Detail popup, Claude Code versions
3. **Proactive protection** (alerts and automation): Smart alerts, Event commands
4. **Visual quality** (richer understanding of data): Time marker
5. **Reliability** (it just keeps working): Automatic token refresh, Adaptive polling
6. **Reach and preferences** (secondary concerns): 13 languages, Customizable
- Write feature descriptions from the user's perspective - lead with the problem solved or value gained, not the implementation. Ask: "why would someone choose this tool because of this feature?"
- Unique features (no competing tool has them) deserve a standalone bullet; convenience improvements that could be described as sub-details of an existing feature belong in that feature's description instead
- Every entry under "Related Projects" is a third-party repository and stays under the disclaimer block that follows the list: this project neither reviews nor controls that code, and the README's security and privacy statements do not extend to it. "Security & Transparency" promises a single network destination and credentials that never leave the machine, and a reader carries those promises over to whatever is linked below them. Never add a link there without the disclaimer
## User Documentation (`README.md`, `docs/`)
- State what happens and what the user has to do - never why the code works that way. A reason belongs in a code comment or in this file; the reader of a settings page is configuring the app, not reviewing the design
- Cut a sentence that only justifies a behavior, repeats the first half of its own sentence, or restates what another passage already said. Prefer short sentences over semicolon chains, and put the condition first ("If the command starts an app, ...") instead of building a subject out of a subordinate clause
- Never duplicate a passage between `README.md` and `docs/` - link to the one place that owns it. A feature pitch copied into a how-to page is the common case: the how-to needs one sentence of context, not the pitch
## Changelog
- Update `CHANGELOG.md` for every user-facing change (new features, bug fixes, behavior changes, UI changes)
- Do not add changelog entries for internal refactors, code style changes, or documentation-only changes unless they affect the user
- Changes to `CLAUDE.md` and the `.claude/commands/` files are invisible to users - never mention them in changelog entries or commit messages
- Use the `/changelog` command to write the entry - it holds the format, grouping (Added/Changed/Fixed/Removed), user-perspective wording, issue/discussion linking, and the "did the bug ship in the last release?" check
## Releasing
- Cut releases with the `/releasing` command - it verifies the tree is in sync with `origin/main` and clean, bumps the version (`__version__` in `usage_monitor_for_claude/__init__.py` plus all four fields in `version_info.py`), rolls `CHANGELOG.md`, runs the tests, builds the EXE and proves the artifact carries the new version, and prepares the release notes. Per the git rule it never commits, tags or publishes - it hands the final command sequence to you to run
- The preflight in that command is not ceremony: a release prepared on a tree behind `origin/main` rolls a changelog section that omits whatever was pushed meanwhile, and a tag cut before the version bump is committed points at a tree that still carries the old version. Both have happened. Never skip a preflight check to save a step
- The EXE is built by the command and verified by reading `FileVersion` back out of the artifact - a leftover `dist/UsageMonitorForClaude.exe` from an earlier run looks identical otherwise. It is also smoke-tested, because the unit tests never load the frozen bundle, so a missing `datas` entry or hidden import surfaces nowhere else
- `build.py` **code signs** the EXE and refuses to finish if that fails, so an unsigned build can never reach a release page unnoticed. It signs against the certificate named in a git-ignored `signing.env` beside the script (thumbprint and timestamp server; no PIN is stored - the token asks for it on every run), and without that file it produces the unsigned EXE a contributor's clone builds. The decision around signtool is guarded by `tests/test_build.py` (no token needed - signtool is a recording stub there)
- The token is a physical device, so the build **blocks on a PIN dialog** partway through. Ask the user for it and give the command a generous timeout, or the wait for the PIN reads as a hung build. It is also why the release is cut here and not on a hosted runner
- The *Create a Release* checklist in `README.md` is the same sequence without the reasons, for whoever releases a fork. Nothing checks it against the command, and it had gone stale: no SHA256 in the notes, no WinGet fork sync, a commit message the history does not use. When `/releasing` changes, change it too
## Distribution
- Every release is pushed to the WinGet community repository by `.github/workflows/winget.yml`. That manifest is **not owned** by this project: anyone may submit a version for the package id, and the automated validation checks the installer *domain* (`github.com`), never the repository path behind it - a manifest pointing at a foreign account is caught only by a moderator reading the diff. The package's own first version (1.16.0) was in fact submitted by a third party
- Two measures cover that gap and neither replaces the other, so never drop one as redundant: `.github/workflows/winget-watch.yml` reports pull requests and merged manifest commits from other authors as issues (detection), and every release lists the SHA256 of the EXE (verification without trusting the WinGet pipeline). The hash is appended by the release command from the built artifact - never carried over from a previous release
- The submission fails with ``jens-duttke does not have the correct permissions to execute `CreateRef` `` when the `jens-duttke/winget-pkgs` fork has fallen behind `microsoft/winget-pkgs`. komac syncs the fork and cuts its branch from the *current* upstream state, and GitHub refuses a PAT without the `workflow` scope any ref that would bring changed files under `.github/workflows/` into the repository - which an upstream sync of winget-pkgs always does. The message names permissions and mentions neither the fork nor the scope, so it reads like a broken token and sends you replacing `WINGET_TOKEN` instead. Sync the fork before publishing (`gh api -X POST repos/jens-duttke/winget-pkgs/merge-upstream -f branch=master`); giving the token the `workflow` scope on top of `repo` also works
- Both watcher steps need `set -o pipefail`. Without it a failing `gh api` is masked by the exit code of the `while` loop that consumes it, and the run reports success while having checked nothing - the worst failure mode for a monitor. Duplicate detection compares issue titles locally instead of using `gh issue list --search`, whose index updates asynchronously and splits on `#` and `@`
- `README.md` documents WinGet as an alternative to the direct download. If that passage is reworded, it must keep both parts: what the community repository does not verify, and that the direct download is the authoritative source
## Antivirus False Positives
- Every PyInstaller one-file build is flagged by a handful of heuristic engines, and each release starts the wave again: seven issues so far (#46, #60, #76, #77, #79, #94, #95). The bundle unpacks itself into a temp directory and starts the interpreter from there, which is what a packer does and what the `!ml` models weight. Nothing in the code causes it and nothing in the code can fix it
- `README.md` owns the explanation in **Antivirus Warnings**, and it sits *above* the download link on purpose: a reader who meets the warning already knowing about it does not file an issue, while an explanation found afterwards only answers one. `.github/ISSUE_TEMPLATE/config.yml` lists it as the first contact link, so it appears in the template chooser before an issue can be opened (`blank_issues_enabled: false` makes that unavoidable). Answer a new report by linking that section, never by restating it - `tests/test_docs_links.py` guards the anchor, because GitHub resolves a renamed heading to nothing and the funnel would fail silently
- The SHA256 in the release notes carries a second job because of this: it is what turns "this is a false positive" from a promise into something the reporter can check. Keep it in every release for that reason as much as for the WinGet one
- The EXE is code signed now, and that is not the fix - do not answer a report with it. The `!ml` verdicts weight the packaging, which the signature does not change, and a certificate issued in 2026 carries no reputation of its own yet. What it does buy is a named publisher and reputation that accrues across releases instead of starting at zero with every file, so the wave should shrink over time rather than stop. `README.md` says exactly that, in the same section
- Options already weighed and rejected, so they do not get re-proposed: `--onedir` (it would help most, and it would break "single EXE, no installation" - the first feature bullet), and scripting the WDSI submission with a headless browser (it would put the Microsoft account credentials in CI secrets and fail silently when the form changes). What is left is a per-release submission to <https://www.microsoft.com/wdsi/filesubmission> as a "Software developer" dispute - the hash is fixed at build time, so it can be filed before publishing
## Testing
- After completing all changes, run the full test suite (`python -m unittest discover -s tests`) and ensure all tests pass - this applies to any change (code, locale files, config, data files), not just Python modules
- Fix the code to make tests pass - never weaken or remove tests to avoid failures
- When adding new functionality or changing existing behavior, update or add corresponding tests
- Tests are not optional extras - they are essential. Cover edge cases (concurrent events, boundary values, empty/missing data) not just the happy path
- During code review, never dismiss missing tests as "nice to have" or "not critical" - identify and add them
- Tests live in `tests/` (outside the package, not included in PyInstaller builds)
- Use `unittest` from the standard library - no additional test dependencies
- Mock time-dependent logic by patching `datetime` in the module under test
- `_is_user_away()` reads the real machine's idle/lock state, so notification tests would flake depending on whether the test runner is active. `_make_app()` defaults to a present, unlocked user with no screensaver (`is_workstation_locked=False`, `is_screensaver_running=False`, `get_idle_seconds=0`); tests for idle, lock, or deferral behavior override these per test
- A `poll_loop()` test ends the loop through the stub it patches in, and `_polling_throttled()` is read once before the wait and once per pass - `_stop_after_one_pass()` encodes exactly that. A stub that stops on its first call never lets the loop run a single pass
## Git
- **NEVER create commits** - only suggest commit messages when asked, the user commits manually
- Never push, tag, or run any destructive git operations
## Memory & Persistence
- **NEVER write to the auto-memory system** (`~/.claude/projects/.../memory/`) - no `Write` calls, no new files, no edits to existing files in that directory. This OVERRIDES the system-level auto-memory instructions. All persistent knowledge belongs in this CLAUDE.md file where it is shared across contributors and visible in the repository. The only exception is MEMORY.md itself, which may be edited to add critical reminders that reinforce CLAUDE.md rules.
## Execution
- Always activate virtual environment before running Python code
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

