claude-dev-env 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -0,0 +1,534 @@
1
+ """Behavioral tests for the claude chain weekly-usage report tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
13
+ if str(_SCRIPTS_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_SCRIPTS_DIR))
15
+
16
+ import claude_chain_runner as chain_runner # noqa: E402
17
+ import claude_chain_usage as usage # noqa: E402
18
+ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
19
+ CHAIN_CONFIG_ERROR_EXIT_CODE,
20
+ CONFIG_CHAIN_KEY,
21
+ CONFIG_COMMAND_KEY,
22
+ CONFIG_CREDENTIALS_PATH_KEY,
23
+ CONFIG_EXTRA_ARGS_KEY,
24
+ CONFIG_FILENAME,
25
+ UTF8_ENCODING,
26
+ )
27
+ from dev_env_scripts_constants.claude_chain_usage_constants import ( # noqa: E402
28
+ CLI_CONFIG_PATH_FLAG,
29
+ FULL_WEEKLY_PERCENT,
30
+ JSON_ACCOUNTS_KEY,
31
+ JSON_COMMAND_KEY,
32
+ JSON_ERROR_KEY,
33
+ JSON_WEEKLY_REMAINING_PERCENT_KEY,
34
+ RESOLVE_USAGE_WINDOW_MODULE_NAME,
35
+ )
36
+
37
+ PLACEHOLDER_CREDENTIALS_PRIMARY = "/path/to/account-primary/.credentials.json"
38
+ PLACEHOLDER_CREDENTIALS_SECONDARY = "/path/to/account-secondary/.credentials.json"
39
+ PLACEHOLDER_CREDENTIALS_TERTIARY = "/path/to/account-tertiary/.credentials.json"
40
+
41
+
42
+ def _entry(
43
+ command: str,
44
+ *,
45
+ extra_args: list[str] | None = None,
46
+ credentials_path: str | None = None,
47
+ ) -> dict[str, object]:
48
+ chain_entry: dict[str, object] = {
49
+ CONFIG_COMMAND_KEY: command,
50
+ CONFIG_EXTRA_ARGS_KEY: extra_args if extra_args is not None else [],
51
+ }
52
+ if credentials_path is not None:
53
+ chain_entry[CONFIG_CREDENTIALS_PATH_KEY] = credentials_path
54
+ return chain_entry
55
+
56
+
57
+ def _write_chain_config(
58
+ tmp_path: Path, all_chain_entries: list[dict[str, object]]
59
+ ) -> Path:
60
+ config_file = tmp_path / CONFIG_FILENAME
61
+ config_file.write_text(
62
+ json.dumps({CONFIG_CHAIN_KEY: all_chain_entries}), encoding=UTF8_ENCODING
63
+ )
64
+ return config_file
65
+
66
+
67
+ def _probe_from_utilization_by_path(
68
+ utilization_by_path: dict[str, float | Exception],
69
+ ) -> usage.WeeklyUtilizationProbe:
70
+ def active_probe(credentials_path: Path) -> float:
71
+ path_key = credentials_path.as_posix()
72
+ probe_outcome = utilization_by_path[path_key]
73
+ if isinstance(probe_outcome, Exception):
74
+ raise probe_outcome
75
+ return probe_outcome
76
+
77
+ return active_probe
78
+
79
+
80
+ def test_remaining_percent_is_full_scale_minus_utilization(
81
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
82
+ ) -> None:
83
+ config_file = _write_chain_config(
84
+ tmp_path,
85
+ [
86
+ _entry("claude", credentials_path=PLACEHOLDER_CREDENTIALS_PRIMARY),
87
+ _entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY),
88
+ ],
89
+ )
90
+ monkeypatch.setattr(
91
+ usage,
92
+ "weekly_utilization_probe",
93
+ _probe_from_utilization_by_path(
94
+ {
95
+ PLACEHOLDER_CREDENTIALS_PRIMARY: 42.0,
96
+ PLACEHOLDER_CREDENTIALS_SECONDARY: 9.0,
97
+ }
98
+ ),
99
+ )
100
+ all_reports = usage.report_chain_weekly_usage(config_path=config_file)
101
+ assert [each_report.command for each_report in all_reports] == [
102
+ "claude",
103
+ "claude-ev",
104
+ ]
105
+ assert all_reports[0].weekly_remaining_percent == pytest.approx(
106
+ FULL_WEEKLY_PERCENT - 42.0
107
+ )
108
+ assert all_reports[1].weekly_remaining_percent == pytest.approx(
109
+ FULL_WEEKLY_PERCENT - 9.0
110
+ )
111
+ assert all_reports[0].error is None
112
+ assert all_reports[1].error is None
113
+
114
+
115
+ def test_rank_orders_by_remaining_desc_preserves_ties_and_puts_nulls_last() -> None:
116
+ all_reports = [
117
+ usage.AccountUsageReport(command="first", weekly_remaining_percent=40.0),
118
+ usage.AccountUsageReport(command="second", weekly_remaining_percent=70.0),
119
+ usage.AccountUsageReport(
120
+ command="third", weekly_remaining_percent=None, error="probe failed"
121
+ ),
122
+ usage.AccountUsageReport(command="fourth", weekly_remaining_percent=70.0),
123
+ usage.AccountUsageReport(
124
+ command="fifth", weekly_remaining_percent=None, error="no token"
125
+ ),
126
+ usage.AccountUsageReport(command="sixth", weekly_remaining_percent=10.0),
127
+ ]
128
+ ranked_reports = usage.rank_accounts_by_weekly_remaining(all_reports)
129
+ assert [each_report.command for each_report in ranked_reports] == [
130
+ "second",
131
+ "fourth",
132
+ "first",
133
+ "sixth",
134
+ "third",
135
+ "fifth",
136
+ ]
137
+
138
+
139
+ def test_probe_failure_yields_null_remaining_and_error_string(
140
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
141
+ ) -> None:
142
+ config_file = _write_chain_config(
143
+ tmp_path,
144
+ [
145
+ _entry("claude", credentials_path=PLACEHOLDER_CREDENTIALS_PRIMARY),
146
+ _entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY),
147
+ ],
148
+ )
149
+ monkeypatch.setattr(
150
+ usage,
151
+ "weekly_utilization_probe",
152
+ _probe_from_utilization_by_path(
153
+ {
154
+ PLACEHOLDER_CREDENTIALS_PRIMARY: usage.WeeklyUtilizationProbeError(
155
+ "token expired"
156
+ ),
157
+ PLACEHOLDER_CREDENTIALS_SECONDARY: 25.0,
158
+ }
159
+ ),
160
+ )
161
+ all_reports = usage.report_chain_weekly_usage(config_path=config_file)
162
+ assert all_reports[0].weekly_remaining_percent is None
163
+ assert all_reports[0].error == "token expired"
164
+ assert all_reports[1].weekly_remaining_percent == pytest.approx(
165
+ FULL_WEEKLY_PERCENT - 25.0
166
+ )
167
+ assert all_reports[1].error is None
168
+
169
+
170
+ def test_missing_config_raises_chain_configuration_error(tmp_path: Path) -> None:
171
+ missing_config = tmp_path / "absent-chain.json"
172
+ with pytest.raises(chain_runner.ChainConfigurationError):
173
+ usage.report_chain_weekly_usage(config_path=missing_config)
174
+
175
+
176
+ def test_empty_config_raises_chain_configuration_error(tmp_path: Path) -> None:
177
+ config_file = _write_chain_config(tmp_path, [])
178
+ with pytest.raises(chain_runner.ChainConfigurationError):
179
+ usage.report_chain_weekly_usage(config_path=config_file)
180
+
181
+
182
+ def test_entry_without_credentials_path_uses_default(
183
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
184
+ ) -> None:
185
+ default_path = Path("/path/to/default/.credentials.json")
186
+ monkeypatch.setattr(usage, "_default_credentials_path", lambda: default_path)
187
+ config_file = _write_chain_config(tmp_path, [_entry("claude")])
188
+ probed_paths: list[Path] = []
189
+
190
+ def active_probe(credentials_path: Path) -> float:
191
+ probed_paths.append(credentials_path)
192
+ return 10.0
193
+
194
+ monkeypatch.setattr(usage, "weekly_utilization_probe", active_probe)
195
+ all_reports = usage.report_chain_weekly_usage(config_path=config_file)
196
+ assert probed_paths == [default_path]
197
+ assert all_reports[0].weekly_remaining_percent == pytest.approx(
198
+ FULL_WEEKLY_PERCENT - 10.0
199
+ )
200
+
201
+
202
+ def test_default_credentials_resolution_failure_yields_null_remaining(
203
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
204
+ ) -> None:
205
+ def raise_probe_error() -> Path:
206
+ raise usage.WeeklyUtilizationProbeError("probe module not found")
207
+
208
+ monkeypatch.setattr(usage, "_default_credentials_path", raise_probe_error)
209
+ config_file = _write_chain_config(tmp_path, [_entry("claude")])
210
+ all_reports = usage.report_chain_weekly_usage(config_path=config_file)
211
+ assert all_reports[0].weekly_remaining_percent is None
212
+ assert all_reports[0].error == "probe module not found"
213
+
214
+
215
+ def test_entry_credentials_path_is_passed_to_probe(
216
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
217
+ ) -> None:
218
+ config_file = _write_chain_config(
219
+ tmp_path,
220
+ [_entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_TERTIARY)],
221
+ )
222
+ probed_paths: list[Path] = []
223
+
224
+ def active_probe(credentials_path: Path) -> float:
225
+ probed_paths.append(credentials_path)
226
+ return 0.0
227
+
228
+ monkeypatch.setattr(usage, "weekly_utilization_probe", active_probe)
229
+ usage.report_chain_weekly_usage(config_path=config_file)
230
+ assert probed_paths == [Path(PLACEHOLDER_CREDENTIALS_TERTIARY)]
231
+
232
+
233
+ def test_entry_credentials_path_expands_user_home(
234
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
235
+ ) -> None:
236
+ tilde_credentials_path = "~/.claude-accounts/secondary/.credentials.json"
237
+ config_file = _write_chain_config(
238
+ tmp_path,
239
+ [_entry("claude-ev", credentials_path=tilde_credentials_path)],
240
+ )
241
+ probed_paths: list[Path] = []
242
+
243
+ def active_probe(credentials_path: Path) -> float:
244
+ probed_paths.append(credentials_path)
245
+ return 0.0
246
+
247
+ monkeypatch.setattr(usage, "weekly_utilization_probe", active_probe)
248
+ usage.report_chain_weekly_usage(config_path=config_file)
249
+ assert probed_paths == [Path(tilde_credentials_path).expanduser()]
250
+
251
+
252
+ def test_load_chain_carries_optional_credentials_path(tmp_path: Path) -> None:
253
+ config_file = _write_chain_config(
254
+ tmp_path,
255
+ [
256
+ _entry("claude"),
257
+ _entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY),
258
+ ],
259
+ )
260
+ all_entries = chain_runner.load_chain(config_file)
261
+ assert all_entries[0].credentials_path is None
262
+ assert all_entries[1].credentials_path == PLACEHOLDER_CREDENTIALS_SECONDARY
263
+
264
+
265
+ def test_invalid_credentials_path_type_raises(tmp_path: Path) -> None:
266
+ config_file = tmp_path / CONFIG_FILENAME
267
+ config_file.write_text(
268
+ json.dumps(
269
+ {
270
+ CONFIG_CHAIN_KEY: [
271
+ {
272
+ CONFIG_COMMAND_KEY: "claude",
273
+ CONFIG_EXTRA_ARGS_KEY: [],
274
+ CONFIG_CREDENTIALS_PATH_KEY: 12,
275
+ }
276
+ ]
277
+ }
278
+ ),
279
+ encoding=UTF8_ENCODING,
280
+ )
281
+ with pytest.raises(chain_runner.ChainConfigurationError):
282
+ chain_runner.load_chain(config_file)
283
+
284
+
285
+ def test_reports_to_json_payload_matches_cli_contract() -> None:
286
+ all_reports = [
287
+ usage.AccountUsageReport(command="claude", weekly_remaining_percent=58.0),
288
+ usage.AccountUsageReport(
289
+ command="claude-ev",
290
+ weekly_remaining_percent=None,
291
+ error="probe failed",
292
+ ),
293
+ ]
294
+ payload = usage.reports_to_json_payload(all_reports)
295
+ assert payload == {
296
+ JSON_ACCOUNTS_KEY: [
297
+ {
298
+ JSON_COMMAND_KEY: "claude",
299
+ JSON_WEEKLY_REMAINING_PERCENT_KEY: 58.0,
300
+ },
301
+ {
302
+ JSON_COMMAND_KEY: "claude-ev",
303
+ JSON_WEEKLY_REMAINING_PERCENT_KEY: None,
304
+ JSON_ERROR_KEY: "probe failed",
305
+ },
306
+ ]
307
+ }
308
+
309
+
310
+ def test_cli_writes_json_accounts_report(
311
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
312
+ ) -> None:
313
+ config_file = _write_chain_config(
314
+ tmp_path,
315
+ [
316
+ _entry("claude", credentials_path=PLACEHOLDER_CREDENTIALS_PRIMARY),
317
+ _entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY),
318
+ ],
319
+ )
320
+ monkeypatch.setattr(
321
+ usage,
322
+ "weekly_utilization_probe",
323
+ _probe_from_utilization_by_path(
324
+ {
325
+ PLACEHOLDER_CREDENTIALS_PRIMARY: 42.0,
326
+ PLACEHOLDER_CREDENTIALS_SECONDARY: usage.WeeklyUtilizationProbeError(
327
+ "no token"
328
+ ),
329
+ }
330
+ ),
331
+ )
332
+ exit_code = usage.main([CLI_CONFIG_PATH_FLAG, str(config_file)])
333
+ captured = capsys.readouterr()
334
+ assert exit_code == 0
335
+ assert captured.err == ""
336
+ payload = json.loads(captured.out)
337
+ assert payload == {
338
+ JSON_ACCOUNTS_KEY: [
339
+ {
340
+ JSON_COMMAND_KEY: "claude",
341
+ JSON_WEEKLY_REMAINING_PERCENT_KEY: FULL_WEEKLY_PERCENT - 42.0,
342
+ },
343
+ {
344
+ JSON_COMMAND_KEY: "claude-ev",
345
+ JSON_WEEKLY_REMAINING_PERCENT_KEY: None,
346
+ JSON_ERROR_KEY: "no token",
347
+ },
348
+ ]
349
+ }
350
+
351
+
352
+ def test_cli_missing_config_prints_stderr_and_returns_config_error(
353
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
354
+ ) -> None:
355
+ missing_config = tmp_path / "absent-chain.json"
356
+ exit_code = usage.main([CLI_CONFIG_PATH_FLAG, str(missing_config)])
357
+ captured = capsys.readouterr()
358
+ assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
359
+ assert captured.out == ""
360
+ assert "not found" in captured.err.lower() or "claude chain" in captured.err.lower()
361
+
362
+
363
+ def test_weekly_remaining_from_utilization_uses_full_scale() -> None:
364
+ assert usage.weekly_remaining_from_utilization(9.0) == pytest.approx(91.0)
365
+ assert usage.weekly_remaining_from_utilization(0.0) == pytest.approx(
366
+ FULL_WEEKLY_PERCENT
367
+ )
368
+ assert usage.weekly_remaining_from_utilization(100.0) == pytest.approx(0.0)
369
+
370
+
371
+ def test_probe_weekly_utilization_reuses_resolver_windows(
372
+ monkeypatch: pytest.MonkeyPatch,
373
+ ) -> None:
374
+ class _FakeWindows:
375
+ weekly_utilization = 37.5
376
+
377
+ class _FakeResolver:
378
+ def read_oauth_access_token(self, credentials_path: Path, now: object) -> str:
379
+ assert credentials_path == Path(PLACEHOLDER_CREDENTIALS_PRIMARY)
380
+ return "token-value"
381
+
382
+ def resolve_access_token(self, credentials_path: Path, now: object) -> str:
383
+ raise AssertionError("probe must not fall back to session ingress")
384
+
385
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
386
+ assert access_token == "token-value"
387
+ return {"seven_day": {"utilization": 37.5}}
388
+
389
+ def extract_usage_windows(
390
+ self, usage_payload: dict[str, object]
391
+ ) -> _FakeWindows:
392
+ assert usage_payload == {"seven_day": {"utilization": 37.5}}
393
+ return _FakeWindows()
394
+
395
+ monkeypatch.setattr(
396
+ usage, "_load_resolve_usage_window_module", lambda: _FakeResolver()
397
+ )
398
+ weekly_utilization = usage._probe_weekly_utilization(
399
+ Path(PLACEHOLDER_CREDENTIALS_PRIMARY)
400
+ )
401
+ assert weekly_utilization == pytest.approx(37.5)
402
+
403
+
404
+ def test_probe_weekly_utilization_raises_when_token_missing(
405
+ monkeypatch: pytest.MonkeyPatch,
406
+ ) -> None:
407
+ class _FakeResolver:
408
+ def read_oauth_access_token(
409
+ self, credentials_path: Path, now: object
410
+ ) -> None:
411
+ return None
412
+
413
+ def resolve_access_token(self, credentials_path: Path, now: object) -> str:
414
+ return "ingress-token-must-not-be-used"
415
+
416
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
417
+ raise AssertionError("must not probe with an ingress fallback token")
418
+
419
+ monkeypatch.setattr(
420
+ usage, "_load_resolve_usage_window_module", lambda: _FakeResolver()
421
+ )
422
+ with pytest.raises(usage.WeeklyUtilizationProbeError):
423
+ usage._probe_weekly_utilization(Path(PLACEHOLDER_CREDENTIALS_PRIMARY))
424
+
425
+
426
+ def test_probe_ignores_ingress_when_credential_token_missing(
427
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
428
+ ) -> None:
429
+ config_file = _write_chain_config(
430
+ tmp_path,
431
+ [_entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY)],
432
+ )
433
+
434
+ class _FakeResolver:
435
+ def read_oauth_access_token(
436
+ self, credentials_path: Path, now: object
437
+ ) -> None:
438
+ return None
439
+
440
+ def resolve_access_token(self, credentials_path: Path, now: object) -> str:
441
+ return "ingress-token-must-not-be-used"
442
+
443
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
444
+ raise AssertionError("must not probe with an ingress fallback token")
445
+
446
+ monkeypatch.setattr(
447
+ usage, "_load_resolve_usage_window_module", lambda: _FakeResolver()
448
+ )
449
+ monkeypatch.setattr(usage, "weekly_utilization_probe", usage._probe_weekly_utilization)
450
+ all_reports = usage.report_chain_weekly_usage(config_path=config_file)
451
+ assert all_reports[0].weekly_remaining_percent is None
452
+ assert all_reports[0].error is not None
453
+ assert "bearer token" in all_reports[0].error.lower()
454
+
455
+
456
+ def test_load_failure_yields_per_account_error_and_cli_exits_zero(
457
+ tmp_path: Path,
458
+ monkeypatch: pytest.MonkeyPatch,
459
+ capsys: pytest.CaptureFixture[str],
460
+ ) -> None:
461
+ config_file = _write_chain_config(
462
+ tmp_path,
463
+ [
464
+ _entry("claude", credentials_path=PLACEHOLDER_CREDENTIALS_PRIMARY),
465
+ _entry("claude-ev", credentials_path=PLACEHOLDER_CREDENTIALS_SECONDARY),
466
+ ],
467
+ )
468
+
469
+ def active_probe(credentials_path: Path) -> float:
470
+ path_key = credentials_path.as_posix()
471
+ if path_key == PLACEHOLDER_CREDENTIALS_PRIMARY:
472
+ raise ImportError("usage probe module failed to import")
473
+ return 25.0
474
+
475
+ monkeypatch.setattr(usage, "weekly_utilization_probe", active_probe)
476
+ exit_code = usage.main([CLI_CONFIG_PATH_FLAG, str(config_file)])
477
+ captured = capsys.readouterr()
478
+ assert exit_code == 0
479
+ payload = json.loads(captured.out)
480
+ assert payload[JSON_ACCOUNTS_KEY][0][JSON_WEEKLY_REMAINING_PERCENT_KEY] is None
481
+ assert "failed to import" in payload[JSON_ACCOUNTS_KEY][0][JSON_ERROR_KEY]
482
+ assert payload[JSON_ACCOUNTS_KEY][1][JSON_WEEKLY_REMAINING_PERCENT_KEY] == pytest.approx(
483
+ FULL_WEEKLY_PERCENT - 25.0
484
+ )
485
+
486
+
487
+ def test_failed_exec_module_does_not_poison_sys_modules(
488
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
489
+ ) -> None:
490
+ if RESOLVE_USAGE_WINDOW_MODULE_NAME in sys.modules:
491
+ del sys.modules[RESOLVE_USAGE_WINDOW_MODULE_NAME]
492
+ (tmp_path / "resolve_usage_window.py").write_text(
493
+ "raise ImportError('intentional load failure')\n", encoding="utf-8"
494
+ )
495
+ monkeypatch.setattr(usage, "_usage_pause_scripts_directory", lambda: tmp_path)
496
+ with pytest.raises(ImportError, match="intentional load failure"):
497
+ usage._load_resolve_usage_window_module()
498
+ assert RESOLVE_USAGE_WINDOW_MODULE_NAME not in sys.modules
499
+
500
+
501
+ def test_load_resolve_usage_window_module_loads_real_probe_api() -> None:
502
+ if RESOLVE_USAGE_WINDOW_MODULE_NAME in sys.modules:
503
+ del sys.modules[RESOLVE_USAGE_WINDOW_MODULE_NAME]
504
+ loaded_module = usage._load_resolve_usage_window_module()
505
+ assert callable(loaded_module.read_oauth_access_token)
506
+ assert callable(loaded_module.resolve_access_token)
507
+ assert callable(loaded_module.extract_usage_windows)
508
+ assert callable(loaded_module._fetch_usage_payload)
509
+ assert sys.modules[RESOLVE_USAGE_WINDOW_MODULE_NAME] is loaded_module
510
+
511
+
512
+ def test_load_resolve_usage_window_module_reuses_cached_module() -> None:
513
+ first_load = usage._load_resolve_usage_window_module()
514
+ second_load = usage._load_resolve_usage_window_module()
515
+ assert first_load is second_load
516
+
517
+
518
+ def test_usage_module_imports_without_preloading_runner() -> None:
519
+ scripts_directory = str(_SCRIPTS_DIR)
520
+ import_probe = (
521
+ "import sys; "
522
+ f"sys.path.insert(0, {scripts_directory!r}); "
523
+ "import claude_chain_usage; "
524
+ "assert callable(claude_chain_usage.report_chain_weekly_usage); "
525
+ "assert callable(claude_chain_usage.rank_accounts_by_weekly_remaining)"
526
+ )
527
+ completed = subprocess.run(
528
+ [sys.executable, "-S", "-E", "-c", import_probe],
529
+ capture_output=True,
530
+ text=True,
531
+ timeout=60,
532
+ check=False,
533
+ )
534
+ assert completed.returncode == 0, completed.stderr