usage-cli 0.32.4__tar.gz → 0.32.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. {usage_cli-0.32.4 → usage_cli-0.32.5}/PKG-INFO +2 -2
  2. {usage_cli-0.32.4 → usage_cli-0.32.5}/README.md +1 -1
  3. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/setup_hook.py +1 -1
  4. {usage_cli-0.32.4 → usage_cli-0.32.5}/pyproject.toml +1 -1
  5. usage_cli-0.32.5/tests/test_check_upstream.py +573 -0
  6. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_statusline.py +162 -0
  7. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/PKG-INFO +2 -2
  8. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/SOURCES.txt +1 -0
  9. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_statusline.py +110 -2
  10. {usage_cli-0.32.4 → usage_cli-0.32.5}/LICENSE +0 -0
  11. {usage_cli-0.32.4 → usage_cli-0.32.5}/THIRD_PARTY_NOTICES.md +0 -0
  12. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/__init__.py +0 -0
  13. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/agy.py +0 -0
  14. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/claude.py +0 -0
  15. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/codex.py +0 -0
  16. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/grok.py +0 -0
  17. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/muse.py +0 -0
  18. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/rate_limits.py +0 -0
  19. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/registry.py +0 -0
  20. {usage_cli-0.32.4 → usage_cli-0.32.5}/adapters/types.py +0 -0
  21. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/__init__.py +0 -0
  22. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/aggregator.py +0 -0
  23. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/blocks.py +0 -0
  24. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/diagnoser.py +0 -0
  25. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/dispatch_ledger.py +0 -0
  26. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/insights.py +0 -0
  27. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/persona_loader.py +0 -0
  28. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/reporter.py +0 -0
  29. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/subscription.py +0 -0
  30. {usage_cli-0.32.4 → usage_cli-0.32.5}/analyzer/usage_snapshot.py +0 -0
  31. {usage_cli-0.32.4 → usage_cli-0.32.5}/doctor.py +0 -0
  32. {usage_cli-0.32.4 → usage_cli-0.32.5}/fsevents_watch.py +0 -0
  33. {usage_cli-0.32.4 → usage_cli-0.32.5}/i18n.json +0 -0
  34. {usage_cli-0.32.4 → usage_cli-0.32.5}/i18n.py +0 -0
  35. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/__init__.py +0 -0
  36. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/claude_pane.py +0 -0
  37. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/codex_hook_trust.py +0 -0
  38. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/login_item.py +0 -0
  39. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/session_hooks.py +0 -0
  40. {usage_cli-0.32.4 → usage_cli-0.32.5}/installer/statusline_settings.py +0 -0
  41. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/__init__.py +0 -0
  42. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/agy_disk_cache.py +0 -0
  43. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/agy_loader.py +0 -0
  44. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/agy_quota_probe.py +0 -0
  45. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/cache_quarantine.py +0 -0
  46. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/claude_paths.py +0 -0
  47. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/codex_disk_cache.py +0 -0
  48. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/codex_events.py +0 -0
  49. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/codex_fork_replay.py +0 -0
  50. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/codex_loader.py +0 -0
  51. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/codex_paths.py +0 -0
  52. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/disk_cache_common.py +0 -0
  53. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/disk_cache_lifecycle.py +0 -0
  54. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/grok_loader.py +0 -0
  55. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/grok_quota_probe.py +0 -0
  56. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/history_disk_cache.py +0 -0
  57. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/history_loader.py +0 -0
  58. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/jsonl_limits.py +0 -0
  59. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/jsonl_utils.py +0 -0
  60. {usage_cli-0.32.4 → usage_cli-0.32.5}/loaders/muse_loader.py +0 -0
  61. {usage_cli-0.32.4 → usage_cli-0.32.5}/main.py +0 -0
  62. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/__init__.py +0 -0
  63. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/actions.py +0 -0
  64. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/agy.py +0 -0
  65. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/app.py +0 -0
  66. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/chrome.py +0 -0
  67. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/grok.py +0 -0
  68. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/manual_refresh.py +0 -0
  69. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/menu.py +0 -0
  70. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/new_badge.py +0 -0
  71. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/notify.py +0 -0
  72. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/popover.py +0 -0
  73. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/prefs.py +0 -0
  74. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/refresh.py +0 -0
  75. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/state.py +0 -0
  76. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/title.py +0 -0
  77. {usage_cli-0.32.4 → usage_cli-0.32.5}/menubar/update.py +0 -0
  78. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/__init__.py +0 -0
  79. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/base.py +0 -0
  80. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/dynamic_height.py +0 -0
  81. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/panel_scale.py +0 -0
  82. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/panel_window.py +0 -0
  83. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/panel_window_state.py +0 -0
  84. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/payload.py +0 -0
  85. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/web_panel.py +0 -0
  86. {usage_cli-0.32.4 → usage_cli-0.32.5}/panels/window_drag.py +0 -0
  87. {usage_cli-0.32.4 → usage_cli-0.32.5}/prefs.py +0 -0
  88. {usage_cli-0.32.4 → usage_cli-0.32.5}/pricing.py +0 -0
  89. {usage_cli-0.32.4 → usage_cli-0.32.5}/project_resolver.py +0 -0
  90. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/__init__.py +0 -0
  91. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/agy_window_keeper.py +0 -0
  92. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/burn_rate.py +0 -0
  93. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/codex_window_keeper.py +0 -0
  94. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/keeper_outcome.py +0 -0
  95. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/usage_rate.py +0 -0
  96. {usage_cli-0.32.4 → usage_cli-0.32.5}/quota/window_keeper.py +0 -0
  97. {usage_cli-0.32.4 → usage_cli-0.32.5}/service_status.py +0 -0
  98. {usage_cli-0.32.4 → usage_cli-0.32.5}/setup.cfg +0 -0
  99. {usage_cli-0.32.4 → usage_cli-0.32.5}/setup_app.py +0 -0
  100. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_adapters_claude.py +0 -0
  101. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_adapters_codex.py +0 -0
  102. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_adapters_rate_limits.py +0 -0
  103. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_aggregator.py +0 -0
  104. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_agy_adapter.py +0 -0
  105. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_agy_disk_cache.py +0 -0
  106. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_agy_loader.py +0 -0
  107. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_agy_quota_probe.py +0 -0
  108. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_agy_window_keeper.py +0 -0
  109. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_analyzer_blocks.py +0 -0
  110. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_analyzer_pipeline.py +0 -0
  111. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_burn_rate.py +0 -0
  112. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_cache_quarantine.py +0 -0
  113. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_claude_pane.py +0 -0
  114. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_claude_paths.py +0 -0
  115. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_codex_consistency.py +0 -0
  116. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_codex_hook_trust.py +0 -0
  117. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_codex_loader.py +0 -0
  118. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_codex_window_keeper.py +0 -0
  119. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_diagnoser.py +0 -0
  120. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_disk_cache_shards.py +0 -0
  121. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_dispatch_ledger.py +0 -0
  122. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_doctor.py +0 -0
  123. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_dynamic_height.py +0 -0
  124. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_fsevents_watch.py +0 -0
  125. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_grok_loader.py +0 -0
  126. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_grok_quota_probe.py +0 -0
  127. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_history_loader.py +0 -0
  128. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_hook_scripts_are_standalone.py +0 -0
  129. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_html_report_snapshot.py +0 -0
  130. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_i18n_format_fallback.py +0 -0
  131. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_i18n_key_parity.py +0 -0
  132. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_i18n_packaged_path.py +0 -0
  133. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_insights.py +0 -0
  134. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_jsonl_utils.py +0 -0
  135. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_keeper_menu.py +0 -0
  136. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_keeper_outcome.py +0 -0
  137. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_login_item.py +0 -0
  138. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_main.py +0 -0
  139. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_manual_refresh.py +0 -0
  140. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar.py +0 -0
  141. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_agy.py +0 -0
  142. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_grok.py +0 -0
  143. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_notify.py +0 -0
  144. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_prefs.py +0 -0
  145. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_state.py +0 -0
  146. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_menubar_update.py +0 -0
  147. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_muse_loader.py +0 -0
  148. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_new_badge.py +0 -0
  149. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_packaged_resources.py +0 -0
  150. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_packaging.py +0 -0
  151. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_panel_footer_layout.py +0 -0
  152. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_panel_scale.py +0 -0
  153. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_panel_window_state.py +0 -0
  154. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_panels.py +0 -0
  155. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_panels_lazy_import.py +0 -0
  156. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_persona_loader.py +0 -0
  157. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_pricing.py +0 -0
  158. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_project_resolver.py +0 -0
  159. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_quota_label.py +0 -0
  160. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_report_daily_chart.py +0 -0
  161. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_report_filter_js.py +0 -0
  162. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_reporter_build_data.py +0 -0
  163. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_service_status.py +0 -0
  164. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_session_resume.py +0 -0
  165. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_setup_app.py +0 -0
  166. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_setup_hook.py +0 -0
  167. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_setup_hook_coexistence.py +0 -0
  168. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_setup_hook_resume.py +0 -0
  169. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_setup_hook_terse.py +0 -0
  170. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_subprocess_utils.py +0 -0
  171. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_subscription.py +0 -0
  172. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_time_utils.py +0 -0
  173. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_tui.py +0 -0
  174. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_tui_sprite.py +0 -0
  175. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_ui_tables.py +0 -0
  176. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_update_checker.py +0 -0
  177. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_update_gate.py +0 -0
  178. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_update_release_notes.py +0 -0
  179. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_cli.py +0 -0
  180. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_client.py +0 -0
  181. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_diagnosis_snapshot.py +0 -0
  182. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_dir_sweeper.py +0 -0
  183. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_lang.py +0 -0
  184. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_logging.py +0 -0
  185. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_notifications.py +0 -0
  186. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_rate.py +0 -0
  187. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_snapshot.py +0 -0
  188. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_snapshot_replay.py +0 -0
  189. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_statusline_agy.py +0 -0
  190. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_statusline_forwarder.py +0 -0
  191. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_statusline_grok.py +0 -0
  192. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_terse_mode.py +0 -0
  193. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_usage_terse_reminder.py +0 -0
  194. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_web_panel_payload.py +0 -0
  195. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_wheel_packaging.py +0 -0
  196. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_win_login_item.py +0 -0
  197. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_window_keeper.py +0 -0
  198. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_windows_manifest.py +0 -0
  199. {usage_cli-0.32.4 → usage_cli-0.32.5}/tests/test_wintray.py +0 -0
  200. {usage_cli-0.32.4 → usage_cli-0.32.5}/tui/__init__.py +0 -0
  201. {usage_cli-0.32.4 → usage_cli-0.32.5}/tui/app.py +0 -0
  202. {usage_cli-0.32.4 → usage_cli-0.32.5}/tui/sprite.py +0 -0
  203. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/__init__.py +0 -0
  204. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/html_report.py +0 -0
  205. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_charts.py +0 -0
  206. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_daily_chart.py +0 -0
  207. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_filter.py +0 -0
  208. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_ledger.py +0 -0
  209. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_scripts.py +0 -0
  210. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/report_styles.py +0 -0
  211. {usage_cli-0.32.4 → usage_cli-0.32.5}/ui/tables.py +0 -0
  212. {usage_cli-0.32.4 → usage_cli-0.32.5}/updates/__init__.py +0 -0
  213. {usage_cli-0.32.4 → usage_cli-0.32.5}/updates/checker.py +0 -0
  214. {usage_cli-0.32.4 → usage_cli-0.32.5}/updates/gate.py +0 -0
  215. {usage_cli-0.32.4 → usage_cli-0.32.5}/updates/release_notes.py +0 -0
  216. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/dependency_links.txt +0 -0
  217. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/entry_points.txt +0 -0
  218. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/requires.txt +0 -0
  219. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.egg-info/top_level.txt +0 -0
  220. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_cli.py +0 -0
  221. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_client.py +0 -0
  222. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/__init__.py +0 -0
  223. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/subprocess_utils.py +0 -0
  224. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/time_utils.py +0 -0
  225. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/usage_dir_sweeper.py +0 -0
  226. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/usage_lang.py +0 -0
  227. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_common/usage_logging.py +0 -0
  228. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_diagnosis_snapshot.py +0 -0
  229. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_notifications.py +0 -0
  230. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_session_resume.py +0 -0
  231. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_statusline_agy.py +0 -0
  232. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_statusline_forwarder.py +0 -0
  233. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_statusline_grok.py +0 -0
  234. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_terse_mode.py +0 -0
  235. {usage_cli-0.32.4 → usage_cli-0.32.5}/usage_terse_reminder.py +0 -0
  236. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/__init__.py +0 -0
  237. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/app.py +0 -0
  238. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/login_item.py +0 -0
  239. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/menu.py +0 -0
  240. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/prefs.py +0 -0
  241. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/quota_label.py +0 -0
  242. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/taskbar_obstacles.py +0 -0
  243. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/taskbar_overlay.py +0 -0
  244. {usage_cli-0.32.4 → usage_cli-0.32.5}/wintray/watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usage-cli
3
- Version: 0.32.4
3
+ Version: 0.32.5
4
4
  Summary: usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)
5
5
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.13
@@ -70,7 +70,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
70
70
  - **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
71
71
  - **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages. Antigravity isn't covered; it has no public status page.
72
72
  - **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
73
- - **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Once it has gone cold, the countdown turns into how many tokens your next message will re-cache. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
73
+ - **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate. For 10 minutes after the cache misses, it also says why — the model changed, the tools changed, you sat idle past the 5-minute TTL, and so on — so you can tell whether the extra tokens came from something you did. The hit rate needs Claude Code 2.1.251 or newer and the reason needs 2.1.260 or newer; on older versions those parts simply don't appear.
74
74
  - **Hide Sections:** Only use one or two of the tools? Hide the Claude Code, Codex, Grok CLI, or Antigravity section from the menu bar and panels completely with a single click.
75
75
 
76
76
  ### Workflow Helpers
@@ -49,7 +49,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
49
49
  - **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
50
50
  - **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages. Antigravity isn't covered; it has no public status page.
51
51
  - **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
52
- - **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Once it has gone cold, the countdown turns into how many tokens your next message will re-cache. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
52
+ - **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate. For 10 minutes after the cache misses, it also says why — the model changed, the tools changed, you sat idle past the 5-minute TTL, and so on — so you can tell whether the extra tokens came from something you did. The hit rate needs Claude Code 2.1.251 or newer and the reason needs 2.1.260 or newer; on older versions those parts simply don't appear.
53
53
  - **Hide Sections:** Only use one or two of the tools? Hide the Claude Code, Codex, Grok CLI, or Antigravity section from the menu bar and panels completely with a single click.
54
54
 
55
55
  ### Workflow Helpers
@@ -86,7 +86,7 @@ PREV_SL_KEY = "previousStatusLine"
86
86
  # Where the status-line switch parks the live statusLine while it is off, so it
87
87
  # never overwrites the pre-install backup under PREV_SL_KEY.
88
88
  DISABLED_SL_KEY = "disabledStatusLine"
89
- HOOK_VERSION = "1.8"
89
+ HOOK_VERSION = "1.9"
90
90
  FORWARDER_VERSION = "1.1"
91
91
  # Antigravity's Go runner hands its status-line command to cmd.exe unquoted, so
92
92
  # every character cmd.exe treats specially has to be kept out of the path.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usage-cli"
3
- version = "0.32.4"
3
+ version = "0.32.5"
4
4
  description = "usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -0,0 +1,573 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ """Upstream checks use synthetic files exclusively, including all installed hooks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ import time
11
+ from contextlib import closing
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import pytest
17
+
18
+ # Loaded by path like test_pricing.py: importing it as scripts.check_upstream makes `mypy .`
19
+ # see the file under two module names, since scripts/ has no __init__.py.
20
+ _spec = importlib.util.spec_from_file_location(
21
+ "check_upstream", Path(__file__).parents[1] / "scripts" / "check_upstream.py"
22
+ )
23
+ assert _spec is not None and _spec.loader is not None
24
+ check = importlib.util.module_from_spec(_spec)
25
+ _spec.loader.exec_module(check)
26
+
27
+
28
+ def write_json(path: Path, value: Any) -> Path:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(json.dumps(value))
31
+ return path
32
+
33
+
34
+ def write_rows(path: Path, rows: list[dict[str, Any]]) -> Path:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
37
+ return path
38
+
39
+
40
+ def now() -> str:
41
+ return datetime.now(UTC).isoformat()
42
+
43
+
44
+ @pytest.fixture(autouse=True)
45
+ def isolate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
46
+ monkeypatch.setattr(check, "CLAUDE_STATUS_PATH", tmp_path / "claude/usage-status.json")
47
+ monkeypatch.setattr(check, "CLAUDE_PROJECTS_DIR", tmp_path / "claude/projects")
48
+ monkeypatch.setattr(check.codex_loader, "SESSIONS_DIR", tmp_path / "codex/sessions")
49
+ for constant, filename in (
50
+ ("STATE_DB", "state_5.sqlite"),
51
+ ("LOGS_DB", "logs_2.sqlite"),
52
+ ("THREAD_HISTORY_DB", "thread_history_1.sqlite"),
53
+ ):
54
+ monkeypatch.setattr(check.codex_loader, constant, tmp_path / "codex" / filename)
55
+ monkeypatch.setattr(check.agy_loader, "AGY_SESSIONS_DIR", tmp_path / "agy/conversations")
56
+ monkeypatch.setattr(
57
+ check.agy_quota_probe, "CACHE_PATH", tmp_path / "usage/agy_quota_cache.json"
58
+ )
59
+ for module in (check.grok_loader, check.grok_quota_probe):
60
+ monkeypatch.setattr(module, "GROK_HOME", tmp_path / "grok")
61
+ monkeypatch.setattr(module, "GROK_LOG_PATH", tmp_path / "grok/logs/unified.jsonl")
62
+ for module, names in (
63
+ (
64
+ check.setup_hook,
65
+ ("HOOK_TARGET", "FORWARDER_TARGET", "AGY_HOOK_TARGET", "GROK_HOOK_TARGET"),
66
+ ),
67
+ (
68
+ check.session_hooks,
69
+ (
70
+ "RESUME_HOOK_TARGET",
71
+ "TERSE_HOOK_TARGET",
72
+ "CODEX_TERSE_HOOK_TARGET",
73
+ "TERSE_REMINDER_HOOK_TARGET",
74
+ ),
75
+ ),
76
+ ):
77
+ for name in names:
78
+ monkeypatch.setattr(module, name, tmp_path / "hooks" / name)
79
+
80
+ def no_network(*args: Any, **kwargs: Any) -> Any:
81
+ pytest.fail("unexpected network request")
82
+
83
+ monkeypatch.setattr(check.service_status.urllib.request, "urlopen", no_network)
84
+ monkeypatch.setattr(check.agy_quota_probe, "probe_quota", no_network)
85
+
86
+
87
+ @pytest.fixture
88
+ def status() -> dict[str, Any]:
89
+ return {
90
+ "_received_at_ts": time.time(),
91
+ "workspace": {"project_dir": "/tmp/project"},
92
+ "model": {"display_name": "Claude"},
93
+ "cost": {"total_duration_ms": 10},
94
+ "context_window": {"used_percentage": 20, "context_window_size": 1000000},
95
+ "prompt_cache": {
96
+ "caching_observed": True,
97
+ "hit_ratio": 0.9,
98
+ "miss_causes": {},
99
+ "last_miss_cause": None,
100
+ },
101
+ "rate_limits": {
102
+ slot: {"used_percentage": 20, "resets_at": time.time() + 3600}
103
+ for slot in ("five_hour", "seven_day")
104
+ },
105
+ }
106
+
107
+
108
+ def test_claude_statusline_ok(status: dict[str, Any]) -> None:
109
+ write_json(check.CLAUDE_STATUS_PATH, status)
110
+ assert check.check_claude_statusline()[0] == "OK"
111
+
112
+
113
+ @pytest.mark.parametrize("location", ["miss_causes", "last_miss_cause"])
114
+ def test_unknown_miss_cause(status: dict[str, Any], location: str) -> None:
115
+ status["prompt_cache"][location] = (
116
+ {"upstream_changed": 1} if location == "miss_causes" else {"causes": ["upstream_changed"]}
117
+ )
118
+ write_json(check.CLAUDE_STATUS_PATH, status)
119
+ state, detail = check.check_claude_statusline()
120
+ assert state == "NEW" and "upstream_changed" in detail
121
+
122
+
123
+ @pytest.mark.parametrize("field", ["rate_limits", "context_window", "prompt_cache"])
124
+ def test_statusline_missing_structure(status: dict[str, Any], field: str) -> None:
125
+ del status[field]
126
+ write_json(check.CLAUDE_STATUS_PATH, status)
127
+ state, detail = check.check_claude_statusline()
128
+ assert state == "BROKEN" and field in detail and str(check.CLAUDE_STATUS_PATH) in detail
129
+
130
+
131
+ def test_statusline_new_fields(status: dict[str, Any]) -> None:
132
+ status["new_top"] = True
133
+ status["rate_limits"]["new_slot"] = {}
134
+ status["prompt_cache"]["new_metric"] = 1
135
+ write_json(check.CLAUDE_STATUS_PATH, status)
136
+ state, detail = check.check_claude_statusline()
137
+ assert state == "NEW"
138
+ assert all(key in detail for key in ("new_top", "new_slot", "new_metric"))
139
+
140
+
141
+ def test_statusline_changed_type(status: dict[str, Any]) -> None:
142
+ status["rate_limits"]["five_hour"]["used_percentage"] = "20"
143
+ write_json(check.CLAUDE_STATUS_PATH, status)
144
+ assert check.check_claude_statusline()[0] == "BROKEN"
145
+
146
+
147
+ def test_received_at_stale(status: dict[str, Any]) -> None:
148
+ status["_received_at_ts"] -= check.MAX_AGE + 10
149
+ write_json(check.CLAUDE_STATUS_PATH, status)
150
+ assert check.check_claude_statusline()[0] == "NO_DATA"
151
+
152
+
153
+ @pytest.fixture
154
+ def assistant() -> dict[str, Any]:
155
+ return {
156
+ "type": "assistant",
157
+ "timestamp": now(),
158
+ "message": {"model": "claude-sonnet", "usage": {key: 10 for key in check.CLAUDE_TOKENS}},
159
+ }
160
+
161
+
162
+ def test_claude_transcript_ok(assistant: dict[str, Any]) -> None:
163
+ write_rows(check.CLAUDE_PROJECTS_DIR / "project/session.jsonl", [assistant])
164
+ assert check.check_claude_transcript()[0] == "OK"
165
+
166
+
167
+ @pytest.mark.parametrize("change,expected", [("missing", "BROKEN"), ("new", "NEW")])
168
+ def test_claude_transcript_changed(assistant: dict[str, Any], change: str, expected: str) -> None:
169
+ if change == "missing":
170
+ del assistant["message"]["usage"]["input_tokens"]
171
+ else:
172
+ assistant["message"]["usage"]["new_tokens"] = 1
173
+ path = write_rows(check.CLAUDE_PROJECTS_DIR / "project/session.jsonl", [assistant])
174
+ state, detail = check.check_claude_transcript()
175
+ assert state == expected and str(path) in detail
176
+
177
+
178
+ def test_claude_partial_rows_allowed(assistant: dict[str, Any]) -> None:
179
+ write_rows(
180
+ check.CLAUDE_PROJECTS_DIR / "project/session.jsonl",
181
+ [assistant, {"type": "assistant", "message": {}}],
182
+ )
183
+ assert check.check_claude_transcript()[0] == "OK"
184
+
185
+
186
+ @pytest.fixture
187
+ def codex_event() -> dict[str, Any]:
188
+ return {
189
+ "type": "event_msg",
190
+ "timestamp": now(),
191
+ "payload": {
192
+ "type": "token_count",
193
+ "info": {"total_token_usage": {key: 10 for key in check.CODEX_TOKENS}},
194
+ "rate_limits": {
195
+ "limit_id": "codex",
196
+ "primary": {
197
+ "used_percent": 10,
198
+ "window_minutes": 300,
199
+ "resets_at": time.time() + 1000,
200
+ },
201
+ "secondary": None,
202
+ "credits": None,
203
+ },
204
+ },
205
+ }
206
+
207
+
208
+ def test_codex_sessions_ok(codex_event: dict[str, Any]) -> None:
209
+ write_rows(check.codex_loader.SESSIONS_DIR / "2026/session.jsonl", [codex_event])
210
+ assert check.check_codex_sessions()[0] == "OK"
211
+
212
+
213
+ @pytest.mark.parametrize("field", ["rate_limits", "info"])
214
+ def test_codex_missing(codex_event: dict[str, Any], field: str) -> None:
215
+ del codex_event["payload"][field]
216
+ write_rows(check.codex_loader.SESSIONS_DIR / "session.jsonl", [codex_event])
217
+ state, detail = check.check_codex_sessions()
218
+ assert state == "BROKEN"
219
+ assert ("rate_limits" if field == "rate_limits" else "total_token_usage") in detail
220
+
221
+
222
+ @pytest.mark.parametrize("location", ["limits", "window", "usage"])
223
+ def test_codex_new(codex_event: dict[str, Any], location: str) -> None:
224
+ payload = codex_event["payload"]
225
+ target = {
226
+ "limits": payload["rate_limits"],
227
+ "window": payload["rate_limits"]["primary"],
228
+ "usage": payload["info"]["total_token_usage"],
229
+ }[location]
230
+ target["upstream_new"] = 1
231
+ write_rows(check.codex_loader.SESSIONS_DIR / "session.jsonl", [codex_event])
232
+ state, detail = check.check_codex_sessions()
233
+ assert state == "NEW" and "upstream_new" in detail
234
+
235
+
236
+ def test_codex_window_type(codex_event: dict[str, Any]) -> None:
237
+ codex_event["payload"]["rate_limits"]["primary"]["window_minutes"] = []
238
+ write_rows(check.codex_loader.SESSIONS_DIR / "session.jsonl", [codex_event])
239
+ assert check.check_codex_sessions()[0] == "BROKEN"
240
+
241
+
242
+ def make_db(path: Path, schema: dict[str, set[str]]) -> Path:
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ with closing(sqlite3.connect(path)) as conn, conn:
245
+ for table, columns in schema.items():
246
+ conn.execute(f'CREATE TABLE "{table}" ({", ".join(sorted(columns))})')
247
+ return path
248
+
249
+
250
+ @pytest.fixture
251
+ def codex_dbs() -> None:
252
+ for constant, schema in check.CODEX_SCHEMAS.items():
253
+ make_db(getattr(check.codex_loader, constant), schema)
254
+
255
+
256
+ def test_codex_sqlite_ok(codex_dbs: None) -> None:
257
+ assert check.check_codex_sqlite()[0] == "OK"
258
+
259
+
260
+ def test_codex_sqlite_new_version(codex_dbs: None) -> None:
261
+ check.codex_loader.STATE_DB.with_name("state_6.sqlite").touch()
262
+ state, detail = check.check_codex_sqlite()
263
+ assert state == "NEW" and "state_6.sqlite" in detail
264
+
265
+
266
+ def test_codex_sqlite_missing_file(codex_dbs: None) -> None:
267
+ check.codex_loader.LOGS_DB.unlink()
268
+ state, detail = check.check_codex_sqlite()
269
+ assert state == "BROKEN" and "logs_2.sqlite" in detail
270
+
271
+
272
+ def test_codex_sqlite_missing_column(codex_dbs: None) -> None:
273
+ with closing(sqlite3.connect(check.codex_loader.STATE_DB)) as conn, conn:
274
+ conn.execute("ALTER TABLE threads DROP COLUMN model")
275
+ state, detail = check.check_codex_sqlite()
276
+ assert state == "BROKEN" and "threads.model" in detail
277
+
278
+
279
+ def test_agy_conversations_ok() -> None:
280
+ make_db(check.agy_loader.AGY_SESSIONS_DIR / "session.db", check.AGY_SCHEMA)
281
+ assert check.check_agy_conversations()[0] == "OK"
282
+
283
+
284
+ def test_agy_conversations_missing_table() -> None:
285
+ path = make_db(
286
+ check.agy_loader.AGY_SESSIONS_DIR / "session.db", {"gen_metadata": {"idx", "data"}}
287
+ )
288
+ state, detail = check.check_agy_conversations()
289
+ assert state == "BROKEN" and "steps.metadata" in detail and str(path) in detail
290
+
291
+
292
+ def test_sqlite_opens_readonly(monkeypatch: pytest.MonkeyPatch) -> None:
293
+ path = make_db(check.agy_loader.AGY_SESSIONS_DIR / "session.db", check.AGY_SCHEMA)
294
+ with closing(sqlite3.connect(path)) as conn, conn:
295
+ conn.execute("PRAGMA journal_mode=WAL")
296
+ before = path.read_bytes()
297
+ original = sqlite3.connect
298
+ uris = []
299
+
300
+ def connect(database: str, **kwargs: Any) -> sqlite3.Connection:
301
+ uris.append(database)
302
+ assert "mode=ro" in database and kwargs["uri"] is True
303
+ conn: sqlite3.Connection = original(database, **kwargs)
304
+ return conn
305
+
306
+ monkeypatch.setattr(check.sqlite3, "connect", connect)
307
+ assert check.check_agy_conversations()[0] == "OK"
308
+ assert uris and before == path.read_bytes()
309
+
310
+
311
+ @pytest.fixture
312
+ def quota() -> dict[str, Any]:
313
+ return {
314
+ "fetched_at": now(),
315
+ "groups": [{"weekly": {"remaining_percent": 40}, "five_hour": {"remaining_percent": 80}}],
316
+ }
317
+
318
+
319
+ def test_agy_quota_ok(quota: dict[str, Any]) -> None:
320
+ write_json(check.agy_quota_probe.CACHE_PATH, quota)
321
+ assert check.check_agy_quota_cache()[0] == "OK"
322
+
323
+
324
+ @pytest.mark.parametrize("missing", ["groups", "weekly", "five_hour"])
325
+ def test_agy_quota_missing(quota: dict[str, Any], missing: str) -> None:
326
+ if missing == "groups":
327
+ quota["groups"] = []
328
+ else:
329
+ del quota["groups"][0][missing]
330
+ write_json(check.agy_quota_probe.CACHE_PATH, quota)
331
+ state, detail = check.check_agy_quota_cache()
332
+ assert state == "BROKEN" and missing in detail
333
+
334
+
335
+ def test_quota_stale_even_with_recent_mtime(quota: dict[str, Any]) -> None:
336
+ quota["fetched_at"] = (datetime.now(UTC) - timedelta(days=8)).isoformat()
337
+ quota["groups"] = []
338
+ write_json(check.agy_quota_probe.CACHE_PATH, quota)
339
+ assert check.check_agy_quota_cache()[0] == "NO_DATA"
340
+
341
+
342
+ @pytest.fixture
343
+ def grok_rows() -> list[dict[str, Any]]:
344
+ return [
345
+ {
346
+ "msg": "shell.turn.inference_done",
347
+ "ts": now(),
348
+ "sid": "session",
349
+ "ctx": {
350
+ "loop_index": 1,
351
+ "prompt_tokens": 10,
352
+ "cached_prompt_tokens": 2,
353
+ "completion_tokens": 3,
354
+ },
355
+ },
356
+ {
357
+ "msg": "billing: fetched credits config",
358
+ "ts": now(),
359
+ "ctx": {"config": {"currentPeriod": {"end": now()}}},
360
+ },
361
+ ]
362
+
363
+
364
+ def test_grok_ok(grok_rows: list[dict[str, Any]]) -> None:
365
+ write_rows(check.grok_loader.GROK_LOG_PATH, grok_rows)
366
+ assert check.check_grok()[0] == "OK"
367
+
368
+
369
+ @pytest.mark.parametrize("missing", ["event", "field"])
370
+ def test_grok_broken(grok_rows: list[dict[str, Any]], missing: str) -> None:
371
+ if missing == "event":
372
+ grok_rows.pop()
373
+ else:
374
+ del grok_rows[0]["ctx"]["prompt_tokens"]
375
+ write_rows(check.grok_loader.GROK_LOG_PATH, grok_rows)
376
+ assert check.check_grok()[0] == "BROKEN"
377
+
378
+
379
+ def test_grok_stale_events(grok_rows: list[dict[str, Any]]) -> None:
380
+ for row in grok_rows:
381
+ row["ts"] = (datetime.now(UTC) - timedelta(days=8)).isoformat()
382
+ write_rows(check.grok_loader.GROK_LOG_PATH, grok_rows)
383
+ assert check.check_grok()[0] == "NO_DATA"
384
+
385
+
386
+ @pytest.mark.parametrize(
387
+ "change,expected",
388
+ [("same", "OK"), ("version", "BROKEN"), ("content", "BROKEN"), ("absent", "NO_DATA")],
389
+ )
390
+ def test_hook_copies(tmp_path: Path, change: str, expected: str) -> None:
391
+ source, target = tmp_path / "source.py", tmp_path / "installed.py"
392
+ source.write_text('__version__ = "1.0"\nprint("new")\n')
393
+ if change != "absent":
394
+ text = source.read_text()
395
+ if change == "version":
396
+ text = text.replace("1.0", "0.9")
397
+ if change == "content":
398
+ text = text.replace("new", "old")
399
+ target.write_text(text)
400
+ state, detail = check.check_hook_copies(source, target)
401
+ assert state == expected
402
+ if change == "content":
403
+ assert "never update" in detail and "installer version constant" in detail
404
+ if change == "version":
405
+ assert "rebuild and reinstall" in detail
406
+
407
+
408
+ def test_service_status_ok(monkeypatch: pytest.MonkeyPatch) -> None:
409
+ monkeypatch.setattr(
410
+ check.service_status,
411
+ "_fetch_status",
412
+ lambda config: {"components": [{"name": name} for name in config.component_names]},
413
+ )
414
+ assert check.check_service_status()[0] == "OK"
415
+
416
+
417
+ def test_service_status_missing_component(monkeypatch: pytest.MonkeyPatch) -> None:
418
+ monkeypatch.setattr(check.service_status, "_fetch_status", lambda config: {"components": []})
419
+ state, detail = check.check_service_status()
420
+ assert state == "BROKEN" and "Claude Code" in detail and "Codex API" in detail
421
+
422
+
423
+ def test_service_offline() -> None:
424
+ assert check.check_service_status(True)[0] == "NO_DATA"
425
+
426
+
427
+ @pytest.mark.parametrize(
428
+ "name",
429
+ [
430
+ "claude_statusline",
431
+ "claude_transcript",
432
+ "codex_sessions",
433
+ "agy_conversations",
434
+ "agy_quota_cache",
435
+ "grok",
436
+ ],
437
+ )
438
+ def test_no_local_data(name: str) -> None:
439
+ assert getattr(check, f"check_{name}")()[0] == "NO_DATA"
440
+
441
+
442
+ def test_stale_transcript(assistant: dict[str, Any]) -> None:
443
+ path = write_rows(check.CLAUDE_PROJECTS_DIR / "project/session.jsonl", [assistant])
444
+ old = time.time() - check.MAX_AGE - 10
445
+ os.utime(path, (old, old))
446
+ assert check.check_claude_transcript()[0] == "NO_DATA"
447
+
448
+
449
+ def test_invalid_json_is_reported() -> None:
450
+ path = check.CLAUDE_STATUS_PATH
451
+ path.parent.mkdir(parents=True)
452
+ path.write_text("{invalid")
453
+ state, detail = check._run(check.check_claude_statusline)
454
+ assert state == "BROKEN" and str(path) in detail
455
+
456
+
457
+ @pytest.mark.parametrize("status,exit_code", [("OK", 0), ("NEW", 0), ("BROKEN", 1)])
458
+ def test_main_exit_and_report(
459
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], status: str, exit_code: int
460
+ ) -> None:
461
+ monkeypatch.setattr(check, "check_codex_sqlite", lambda: (status, "sample"))
462
+ monkeypatch.setattr(check.sys, "argv", ["check_upstream.py", "--offline"])
463
+ assert check.main() == exit_code
464
+ output = capsys.readouterr().out.splitlines()
465
+ assert len(output) == 17
466
+ assert f"{status} codex_sqlite — sample" in output
467
+ assert output[-1].startswith("TOTAL OK=")
468
+
469
+
470
+ def test_main_readonly(
471
+ tmp_path: Path,
472
+ monkeypatch: pytest.MonkeyPatch,
473
+ status: dict[str, Any],
474
+ quota: dict[str, Any],
475
+ assistant: dict[str, Any],
476
+ codex_event: dict[str, Any],
477
+ codex_dbs: None,
478
+ grok_rows: list[dict[str, Any]],
479
+ ) -> None:
480
+ write_json(check.CLAUDE_STATUS_PATH, status)
481
+ write_json(check.agy_quota_probe.CACHE_PATH, quota)
482
+ write_rows(check.CLAUDE_PROJECTS_DIR / "project/session.jsonl", [assistant])
483
+ write_rows(check.codex_loader.SESSIONS_DIR / "session.jsonl", [codex_event])
484
+ write_rows(check.grok_loader.GROK_LOG_PATH, grok_rows)
485
+ make_db(check.agy_loader.AGY_SESSIONS_DIR / "session.db", check.AGY_SCHEMA)
486
+ before = {str(p): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
487
+ monkeypatch.setattr(check.sys, "argv", ["check_upstream.py", "--offline"])
488
+ assert check.main() == 0
489
+ assert before == {str(p): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
490
+
491
+
492
+ @pytest.mark.parametrize("changed", [False, True])
493
+ def test_unversioned_hooks(tmp_path: Path, changed: bool) -> None:
494
+ source, target = tmp_path / "usage_statusline_agy.py", tmp_path / "installed.py"
495
+ source.write_text('print("new")\n')
496
+ target.write_text('print("old")\n' if changed else source.read_text())
497
+ assert check.check_hook_copies(source, target)[0] == ("BROKEN" if changed else "OK")
498
+
499
+
500
+ def test_known_miss_causes_from_translations(status: dict[str, Any]) -> None:
501
+ reasons = [
502
+ key.removeprefix("miss_")
503
+ for key in check.usage_statusline.STATUSLINE_TRANSLATIONS["en"]
504
+ if key.startswith("miss_")
505
+ ] + ["unknown"]
506
+ status["prompt_cache"]["miss_causes"] = dict.fromkeys(reasons, 1)
507
+ status["prompt_cache"]["last_miss_cause"] = {"causes": reasons}
508
+ write_json(check.CLAUDE_STATUS_PATH, status)
509
+ assert check.check_claude_statusline()[0] == "OK"
510
+
511
+
512
+ def test_grok_changed_token_type(grok_rows: list[dict[str, Any]]) -> None:
513
+ grok_rows[0]["ctx"]["prompt_tokens"] = "bad"
514
+ write_rows(check.grok_loader.GROK_LOG_PATH, grok_rows)
515
+ state, detail = check.check_grok()
516
+ assert state == "BROKEN" and "prompt_tokens" in detail
517
+
518
+
519
+ def test_sqlite_sees_active_wal() -> None:
520
+ path = check.agy_loader.AGY_SESSIONS_DIR / "session.db"
521
+ path.parent.mkdir(parents=True)
522
+ with closing(sqlite3.connect(path)) as conn:
523
+ conn.execute("PRAGMA journal_mode=WAL")
524
+ conn.execute("PRAGMA wal_autocheckpoint=0")
525
+ for table, columns in check.AGY_SCHEMA.items():
526
+ conn.execute(f'CREATE TABLE "{table}" ({", ".join(sorted(columns))})')
527
+ conn.commit()
528
+ assert Path(str(path) + "-wal").stat().st_size > 0
529
+ assert check.check_agy_conversations()[0] == "OK"
530
+
531
+
532
+ def test_all_sqlite_stale(codex_dbs: None) -> None:
533
+ old = time.time() - check.MAX_AGE - 10
534
+ for constant in check.CODEX_SCHEMAS:
535
+ os.utime(getattr(check.codex_loader, constant), (old, old))
536
+ assert check.check_codex_sqlite()[0] == "NO_DATA"
537
+
538
+
539
+ def test_service_failed_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
540
+ monkeypatch.setattr(check.service_status, "_fetch_status", lambda config: None)
541
+ assert check.check_service_status()[0] == "BROKEN"
542
+
543
+
544
+ def test_sqlite_changed_column_in_wal() -> None:
545
+ path = make_db(check.agy_loader.AGY_SESSIONS_DIR / "session.db", check.AGY_SCHEMA)
546
+ with closing(sqlite3.connect(path)) as conn:
547
+ conn.execute("PRAGMA journal_mode=WAL")
548
+ conn.execute("ALTER TABLE gen_metadata DROP COLUMN data")
549
+ conn.commit()
550
+ state, detail = check.check_agy_conversations()
551
+ assert state == "BROKEN" and "gen_metadata.data" in detail
552
+
553
+
554
+ def test_sqlite_uncommitted_schema_ignored() -> None:
555
+ path = make_db(check.agy_loader.AGY_SESSIONS_DIR / "session.db", check.AGY_SCHEMA)
556
+ with closing(sqlite3.connect(path)) as conn:
557
+ conn.execute("PRAGMA journal_mode=WAL")
558
+ conn.execute("BEGIN")
559
+ conn.execute("ALTER TABLE gen_metadata DROP COLUMN data")
560
+ assert check.check_agy_conversations()[0] == "OK"
561
+ conn.rollback()
562
+
563
+
564
+ def test_sqlite_recent_wal_overrides_old_database(codex_dbs: None) -> None:
565
+ path = check.codex_loader.STATE_DB
566
+ with closing(sqlite3.connect(path)) as conn:
567
+ conn.execute("PRAGMA journal_mode=WAL")
568
+ conn.execute("INSERT INTO threads(id, model, cwd) VALUES ('s', 'gpt', '/tmp')")
569
+ conn.commit()
570
+ old = time.time() - check.MAX_AGE - 10
571
+ os.utime(path, (old, old))
572
+ state, detail = check.check_codex_sqlite()
573
+ assert state == "OK" and str(path) in detail and "stale:" not in detail