claude-dev-env 8.84.0 → 8.84.1

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.
@@ -21,10 +21,20 @@ from hooks_constants.pull_request_proof_constants import (
21
21
  ALL_VISIBLE_FILE_SUFFIXES,
22
22
  CHANGED_FILES_SEPARATOR,
23
23
  COMMAND_MARKER,
24
+ ALL_BASE_FLAGS,
24
25
  ALL_DEFAULT_BRANCH_REFERENCES,
25
26
  ALL_HEAD_FLAGS,
27
+ ALL_REPOSITORY_FLAGS,
28
+ BASE_FLAG_ASSIGNMENT_PREFIX,
29
+ COMPARE_ENDPOINT_TEMPLATE,
30
+ COMPARE_FILE_LIST_LIMIT,
31
+ COMPARE_FILE_NAMES_QUERY,
32
+ DEFAULT_BRANCH_QUERY,
33
+ GH_TIMEOUT_SECONDS,
26
34
  HEAD_FLAG_ASSIGNMENT_PREFIX,
27
35
  REMOTE_BRANCH_PREFIX,
36
+ REPOSITORY_ENDPOINT_TEMPLATE,
37
+ REPOSITORY_FLAG_ASSIGNMENT_PREFIX,
28
38
  UNREADABLE_CHANGES_REASON,
29
39
  EVAL_HEADING_PATTERN,
30
40
  EXISTING_WORK_HEADING_PATTERN,
@@ -174,28 +184,55 @@ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall
174
184
  ]
175
185
 
176
186
 
177
- def _head_flag_value(all_create_arguments: list[str]) -> str | None:
187
+ def _flag_value(all_create_arguments: list[str], all_flags: frozenset[str], assignment_prefix: str) -> str | None:
178
188
  for each_position, each_argument in enumerate(all_create_arguments):
179
- if each_argument in ALL_HEAD_FLAGS and each_position + 1 < len(all_create_arguments):
180
- return all_create_arguments[each_position + 1]
181
- if each_argument.startswith(HEAD_FLAG_ASSIGNMENT_PREFIX):
182
- return each_argument[len(HEAD_FLAG_ASSIGNMENT_PREFIX) :]
189
+ if each_argument in all_flags and each_position + 1 < len(all_create_arguments):
190
+ return unquoted_token(all_create_arguments[each_position + 1])
191
+ if each_argument.startswith(assignment_prefix):
192
+ return unquoted_token(each_argument[len(assignment_prefix) :])
183
193
  return None
184
194
 
185
195
 
186
- def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
187
- create_fields = create_tool_fields(all_payload_fields)
188
- head_branch = None if create_fields is None else create_fields.get("head")
189
- if isinstance(head_branch, str):
190
- return head_branch
196
+ def _shell_flag_value(
197
+ all_payload_fields: Mapping[str, object], all_flags: frozenset[str], assignment_prefix: str
198
+ ) -> str | None:
191
199
  command = _shell_command(all_payload_fields)
192
200
  if command is None:
193
201
  return None
194
- all_head_flag_values = [
195
- _head_flag_value(all_create_arguments)
202
+ all_flag_values = [
203
+ _flag_value(all_create_arguments, all_flags, assignment_prefix)
196
204
  for all_create_arguments in _all_create_argument_lists(command)
197
205
  ]
198
- return next((each_value for each_value in all_head_flag_values if each_value), None)
206
+ return next((each_value for each_value in all_flag_values if each_value), None)
207
+
208
+
209
+ def _named_create_field(
210
+ all_payload_fields: Mapping[str, object], field_name: str, all_flags: frozenset[str], assignment_prefix: str
211
+ ) -> str | None:
212
+ create_fields = create_tool_fields(all_payload_fields)
213
+ field_value = None if create_fields is None else create_fields.get(field_name)
214
+ if isinstance(field_value, str) and field_value:
215
+ return field_value
216
+ return _shell_flag_value(all_payload_fields, all_flags, assignment_prefix)
217
+
218
+
219
+ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
220
+ return _named_create_field(all_payload_fields, "head", ALL_HEAD_FLAGS, HEAD_FLAG_ASSIGNMENT_PREFIX)
221
+
222
+
223
+ def _named_base_branch(all_payload_fields: Mapping[str, object]) -> str | None:
224
+ return _named_create_field(all_payload_fields, "base", ALL_BASE_FLAGS, BASE_FLAG_ASSIGNMENT_PREFIX)
225
+
226
+
227
+ def _named_repository(all_payload_fields: Mapping[str, object]) -> str | None:
228
+ create_fields = create_tool_fields(all_payload_fields)
229
+ if create_fields is not None:
230
+ owner = create_fields.get("owner")
231
+ repository_name = create_fields.get("repo")
232
+ if isinstance(owner, str) and owner and isinstance(repository_name, str) and repository_name:
233
+ return f"{owner}/{repository_name}"
234
+ return None
235
+ return _shell_flag_value(all_payload_fields, ALL_REPOSITORY_FLAGS, REPOSITORY_FLAG_ASSIGNMENT_PREFIX)
199
236
 
200
237
 
201
238
  def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
@@ -485,9 +522,83 @@ def visible_changed_files(working_directory: str, head_branch: object) -> list[s
485
522
  all_changed_paths = _git_lines(working_directory, "diff", "--name-only", merge_base, head_reference)
486
523
  if all_changed_paths is None:
487
524
  return None
525
+ return _visible_paths(all_changed_paths)
526
+
527
+
528
+ def _visible_paths(all_changed_paths: list[str]) -> list[str]:
488
529
  return [each_path for each_path in all_changed_paths if each_path.lower().endswith(ALL_VISIBLE_FILE_SUFFIXES)]
489
530
 
490
531
 
532
+ def _gh_api_lines(endpoint: str, query: str) -> list[str] | None:
533
+ try:
534
+ completed = subprocess.run(
535
+ ["gh", "api", endpoint, "--jq", query],
536
+ capture_output=True,
537
+ text=True,
538
+ timeout=GH_TIMEOUT_SECONDS,
539
+ check=False,
540
+ )
541
+ except (OSError, subprocess.SubprocessError):
542
+ return None
543
+ return completed.stdout.splitlines() if completed.returncode == 0 else None
544
+
545
+
546
+ def _compare_base_branch(repository: str, base_branch: str | None) -> str | None:
547
+ if base_branch:
548
+ return base_branch
549
+ all_default_branch_lines = _gh_api_lines(
550
+ REPOSITORY_ENDPOINT_TEMPLATE.format(repository=repository), DEFAULT_BRANCH_QUERY
551
+ )
552
+ if not all_default_branch_lines:
553
+ return None
554
+ return all_default_branch_lines[0]
555
+
556
+
557
+ def _visible_compare_paths(repository: str, base_branch: str, head_branch: str) -> list[str] | None:
558
+ all_changed_paths = _gh_api_lines(
559
+ COMPARE_ENDPOINT_TEMPLATE.format(
560
+ repository=repository, base_branch=base_branch, head_branch=head_branch
561
+ ),
562
+ COMPARE_FILE_NAMES_QUERY,
563
+ )
564
+ if all_changed_paths is None:
565
+ return None
566
+ all_visible_paths = _visible_paths(all_changed_paths)
567
+ if all_visible_paths or len(all_changed_paths) < COMPARE_FILE_LIST_LIMIT:
568
+ return all_visible_paths
569
+ return None
570
+
571
+
572
+ def remote_visible_changed_files(
573
+ repository: str | None, base_branch: str | None, head_branch: str | None
574
+ ) -> list[str] | None:
575
+ """Return the changed files people see, read from the GitHub compare API, or None when it cannot tell.
576
+
577
+ ::
578
+
579
+ compare lists tool.py -> []
580
+ compare lists web/page.html -> ["web/page.html"]
581
+ compare returns 300 paths and none are visible -> None
582
+ repository or head missing -> None
583
+
584
+ The base is the named base branch, else the repository's default branch.
585
+ A page of 300 paths is the compare API's file cap. A visible file on that
586
+ page is returned. A full page with none is None, because a later file can
587
+ still be one people see.
588
+
589
+ Args:
590
+ repository: The owner/name the create call names, or None.
591
+ base_branch: The base branch the create call names, or None.
592
+ head_branch: The head branch the create call names, or None.
593
+ """
594
+ if not repository or not head_branch:
595
+ return None
596
+ resolved_base_branch = _compare_base_branch(repository, base_branch)
597
+ if resolved_base_branch is None:
598
+ return None
599
+ return _visible_compare_paths(repository, resolved_base_branch, head_branch)
600
+
601
+
491
602
  def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
492
603
  """Return a deny reason when a pull request changing visible files shows no picture, else None.
493
604
 
@@ -496,7 +607,9 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
496
607
  branch changes page.html, proof has ![after](https://x/after.png) -> None
497
608
  branch changes page.html, proof quotes only command output -> MISSING_LOOK_REASON
498
609
  branch changes only .py files -> None
499
- git cannot read the branch, proof shows no picture -> UNREADABLE_CHANGES_REASON
610
+ git cannot read the branch, compare API lists only .py files -> None
611
+ git cannot read the branch, compare API returns 300 .py files -> UNREADABLE_CHANGES_REASON
612
+ neither git nor the compare API can read it, no picture -> UNREADABLE_CHANGES_REASON
500
613
 
501
614
  Args:
502
615
  all_payload_fields: The parsed PreToolUse input.
@@ -505,9 +618,12 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
505
618
  if not all_bodies:
506
619
  return None
507
620
  working_directory = all_payload_fields.get("cwd")
508
- all_visible_files = visible_changed_files(
509
- str(working_directory or ""), _named_head_branch(all_payload_fields)
510
- )
621
+ head_branch = _named_head_branch(all_payload_fields)
622
+ all_visible_files = visible_changed_files(str(working_directory or ""), head_branch)
623
+ if all_visible_files is None:
624
+ all_visible_files = remote_visible_changed_files(
625
+ _named_repository(all_payload_fields), _named_base_branch(all_payload_fields), head_branch
626
+ )
511
627
  if all_visible_files == []:
512
628
  return None
513
629
  for each_body in all_bodies:
@@ -4,12 +4,15 @@ import subprocess
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
+ import pytest
8
+
7
9
  HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
8
10
  if str(HOOKS_DIRECTORY) not in sys.path:
9
11
  sys.path.insert(0, str(HOOKS_DIRECTORY))
10
12
 
11
13
  from blocking import pull_request_proof as gate
12
14
  from hooks_constants.pull_request_proof_constants import (
15
+ COMPARE_FILE_LIST_LIMIT,
13
16
  MISSING_BODY_REASON,
14
17
  MISSING_EVAL_SECTION_REASON,
15
18
  MISSING_EXISTING_WORK_REASON,
@@ -306,6 +309,147 @@ def test_should_deny_when_git_cannot_read_the_branch_and_proof_shows_no_picture(
306
309
  assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
307
310
 
308
311
 
312
+ def _fake_gh_api(all_responses: dict[str, list[str] | None], all_endpoints: list[str]):
313
+ def fake_gh_api_lines(endpoint: str, query: str) -> list[str] | None:
314
+ all_endpoints.append(endpoint)
315
+ return all_responses.get(endpoint)
316
+
317
+ return fake_gh_api_lines
318
+
319
+
320
+ def _shell_create_elsewhere(tmp_path: Path, extra_flags: str) -> dict[str, object]:
321
+ body_path = tmp_path / "body.md"
322
+ body_path.write_text(PROVEN_BODY, encoding="utf-8")
323
+ return _shell(f"gh pr create --title t --body-file {body_path} {extra_flags}", tmp_path)
324
+
325
+
326
+ def test_should_allow_a_python_only_change_the_compare_api_lists_when_git_cannot_read_it(
327
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
328
+ ) -> None:
329
+ all_endpoints: list[str] = []
330
+ compare_endpoint = "repos/o/r/compare/main...feature"
331
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
332
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
333
+ assert gate.missing_look_reason(payload) is None
334
+ assert all_endpoints == [compare_endpoint]
335
+
336
+
337
+ def test_should_deny_a_page_change_the_compare_api_lists_without_a_picture(
338
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
339
+ ) -> None:
340
+ all_responses = {"repos/o/r": ["trunk"], "repos/o/r/compare/trunk...feature": ["web/page.html", "tool.py"]}
341
+ all_endpoints: list[str] = []
342
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api(all_responses, all_endpoints))
343
+ reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "-R o/r --head=feature"))
344
+ assert reason is not None
345
+ assert "web/page.html" in reason
346
+ assert "tool.py" not in reason
347
+ assert all_endpoints == ["repos/o/r", "repos/o/r/compare/trunk...feature"]
348
+
349
+
350
+ def test_should_deny_as_unreadable_when_the_compare_api_fails(
351
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
352
+ ) -> None:
353
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, []))
354
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
355
+ reason = gate.missing_look_reason(payload)
356
+ assert reason == UNREADABLE_CHANGES_REASON
357
+ assert reason is not None
358
+ assert "compare API" in reason
359
+ assert "--repo" in reason
360
+ assert "--head" in reason
361
+ assert "origin fetched" in reason
362
+ assert "Artifact" in reason
363
+ assert "before any change to this gate" in reason
364
+
365
+
366
+ def _python_paths(count: int) -> list[str]:
367
+ return [f"pkg/module_{each_index}.py" for each_index in range(count)]
368
+
369
+
370
+ def test_should_deny_as_unreadable_when_a_full_compare_page_lists_no_visible_file(
371
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
372
+ ) -> None:
373
+ all_endpoints: list[str] = []
374
+ compare_endpoint = "repos/o/r/compare/main...feature"
375
+ monkeypatch.setattr(
376
+ gate,
377
+ "_gh_api_lines",
378
+ _fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT)}, all_endpoints),
379
+ )
380
+ assert COMPARE_FILE_LIST_LIMIT == 300
381
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
382
+ assert gate.remote_visible_changed_files("o/r", "main", "feature") is None
383
+ assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
384
+ assert all_endpoints == [compare_endpoint, compare_endpoint]
385
+
386
+
387
+ def test_should_allow_a_short_compare_page_that_lists_only_python(
388
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
389
+ ) -> None:
390
+ all_endpoints: list[str] = []
391
+ compare_endpoint = "repos/o/r/compare/main...feature"
392
+ monkeypatch.setattr(
393
+ gate,
394
+ "_gh_api_lines",
395
+ _fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT - 1)}, all_endpoints),
396
+ )
397
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
398
+ assert gate.remote_visible_changed_files("o/r", "main", "feature") == []
399
+ assert gate.missing_look_reason(payload) is None
400
+
401
+
402
+ def test_should_deny_a_visible_file_on_a_full_compare_page(
403
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
404
+ ) -> None:
405
+ all_endpoints: list[str] = []
406
+ compare_endpoint = "repos/o/r/compare/main...feature"
407
+ all_paths = [*_python_paths(COMPARE_FILE_LIST_LIMIT - 1), "web/page.html"]
408
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: all_paths}, all_endpoints))
409
+ reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature"))
410
+ assert reason is not None
411
+ assert "web/page.html" in reason
412
+ assert "module_0.py" not in reason
413
+
414
+
415
+ def test_should_allow_a_page_the_compare_api_lists_when_the_proof_links_an_artifact(
416
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
417
+ ) -> None:
418
+ all_endpoints: list[str] = []
419
+ compare_endpoint = "repos/o/r/compare/main...feature"
420
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["page.html"]}, all_endpoints))
421
+ body_path = tmp_path / "body.md"
422
+ body_path.write_text(PROVEN_BODY + "https://claude.ai/code/artifact/abc-123\n", encoding="utf-8")
423
+ payload = _shell(
424
+ f"gh pr create --title t --body-file {body_path} --repo o/r --base main --head feature",
425
+ tmp_path,
426
+ )
427
+ assert gate.missing_look_reason(payload) is None
428
+ assert all_endpoints == [compare_endpoint]
429
+
430
+
431
+ def test_should_skip_the_compare_api_without_a_named_repository(
432
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
433
+ ) -> None:
434
+ all_endpoints: list[str] = []
435
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, all_endpoints))
436
+ payload = _shell_create_elsewhere(tmp_path, "--base main --head feature")
437
+ assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
438
+ assert all_endpoints == []
439
+
440
+
441
+ def test_should_read_owner_and_repo_of_an_mcp_create_for_the_compare_api(
442
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
443
+ ) -> None:
444
+ all_endpoints: list[str] = []
445
+ compare_endpoint = "repos/o/r/compare/main...feature"
446
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
447
+ payload = _mcp_create_on_branch(PROVEN_BODY, tmp_path)
448
+ payload["tool_input"].update(owner="o", repo="r", base="main")
449
+ assert gate.missing_look_reason(payload) is None
450
+ assert all_endpoints == [compare_endpoint]
451
+
452
+
309
453
  def test_visible_changed_files_falls_back_to_origin_main_without_origin_head(tmp_path: Path) -> None:
310
454
  clone_path = _repository_with_change(tmp_path, "page.html")
311
455
  subprocess.run(["git", "-C", str(clone_path), "remote", "set-head", "origin", "-d"], check=True)
@@ -85,7 +85,17 @@ ALL_DEFAULT_BRANCH_REFERENCES = ("origin/HEAD", "origin/main", "main")
85
85
  REMOTE_BRANCH_PREFIX = "origin/"
86
86
  ALL_HEAD_FLAGS = frozenset({"--head", "-H"})
87
87
  HEAD_FLAG_ASSIGNMENT_PREFIX = "--head="
88
+ ALL_BASE_FLAGS = frozenset({"--base", "-B"})
89
+ BASE_FLAG_ASSIGNMENT_PREFIX = "--base="
90
+ ALL_REPOSITORY_FLAGS = frozenset({"--repo", "-R"})
91
+ REPOSITORY_FLAG_ASSIGNMENT_PREFIX = "--repo="
88
92
  GIT_TIMEOUT_SECONDS = 5
93
+ GH_TIMEOUT_SECONDS = 15
94
+ COMPARE_ENDPOINT_TEMPLATE = "repos/{repository}/compare/{base_branch}...{head_branch}"
95
+ COMPARE_FILE_NAMES_QUERY = ".files[].filename"
96
+ COMPARE_FILE_LIST_LIMIT = 300
97
+ REPOSITORY_ENDPOINT_TEMPLATE = "repos/{repository}"
98
+ DEFAULT_BRANCH_QUERY = ".default_branch"
89
99
  MISSING_LOOK_REASON = (
90
100
  "This pull request changes files people see ({changed_files}), and its 'Proof in"
91
101
  " practice' section shows no picture. Render the change, open the screenshot with the"
@@ -96,11 +106,14 @@ MISSING_LOOK_REASON = (
96
106
  )
97
107
  CHANGED_FILES_SEPARATOR = ", "
98
108
  UNREADABLE_CHANGES_REASON = (
99
- "Git in this directory cannot list the files this pull request changes, so the"
100
- " picture check cannot tell whether people see the change. Put a screenshot or"
101
- " Artifact link in the 'Proof in practice' section and run the create call again,"
102
- " or run it from a checkout of the head branch with origin fetched. Take one of"
103
- " these routes now, before any change to this gate. Read " + PROOF_GUIDE_PATH + " for the full rule."
109
+ "Neither git in this directory nor the GitHub compare API can list the files this"
110
+ " pull request changes, so the picture check cannot tell whether people see the change."
111
+ " Name the repository and head branch with --repo and --head, run the create call"
112
+ " from a checkout of the head branch with origin fetched, or put a screenshot or"
113
+ " Artifact link in the 'Proof in practice' section and run the create call again."
114
+ " Take one of these routes now, before any change to this gate. Read "
115
+ + PROOF_GUIDE_PATH
116
+ + " for the full rule."
104
117
  )
105
118
  FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
106
119
  EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.84.0",
3
+ "version": "8.84.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@ is in place by the time any test module runs its top-level imports.
18
18
  from __future__ import annotations
19
19
 
20
20
  import importlib
21
+ import site
21
22
  import subprocess
22
23
  import sys
23
24
  from collections.abc import Callable
@@ -35,25 +36,33 @@ for each_import_directory in (
35
36
  if str(each_import_directory) not in sys.path:
36
37
  sys.path.insert(0, str(each_import_directory))
37
38
 
38
- _live_broker_state_resolver = importlib.import_module("account_broker_support").broker_state_path
39
+ from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
39
40
 
40
41
 
41
42
  @pytest.fixture(autouse=True)
42
- def isolated_broker_state_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
43
- """Point every module's ``broker_state_path`` at a per-test file.
44
-
45
- ``account_broker`` binds ``broker_state_path`` by name from
46
- ``account_broker_support``, so each bound copy is replaced. A test that
47
- patches only one copy still keeps the other off the home directory.
43
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
44
+ """Point the home directory at a per-test folder for every test here.
45
+
46
+ ``Path.home()`` reads ``HOME`` on POSIX and ``USERPROFILE`` on Windows, so
47
+ redirecting both keeps call-time home lookups, such as
48
+ ``broker_state_path``, off the user's home directory. The variables in
49
+ ``ALL_ACCOUNT_ENVIRONMENT_VARIABLES`` are cleared, so account roots fall
50
+ back to the per-test home and no developer roster applies. A value built
51
+ from ``Path.home()`` at import time keeps the user's home.
52
+ ``PYTHONUSERBASE`` stays on the user base captured before the redirect, so
53
+ a new interpreter still imports packages installed for this user.
48
54
 
49
55
  Returns:
50
- The isolated broker state path for this test.
56
+ The per-test home directory.
51
57
  """
52
- isolated_path = tmp_path / "account-broker" / "state.json"
53
- for each_module in list(sys.modules.values()):
54
- if getattr(each_module, "broker_state_path", None) is _live_broker_state_resolver:
55
- monkeypatch.setattr(each_module, "broker_state_path", lambda: isolated_path)
56
- return isolated_path
58
+ user_base = site.getuserbase()
59
+ isolated_home_directory = tmp_path / "home"
60
+ monkeypatch.setenv("PYTHONUSERBASE", user_base)
61
+ monkeypatch.setenv("HOME", str(isolated_home_directory))
62
+ monkeypatch.setenv("USERPROFILE", str(isolated_home_directory))
63
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
64
+ monkeypatch.delenv(each_account_variable, raising=False)
65
+ return isolated_home_directory
57
66
 
58
67
 
59
68
  def _link_directory(*, from_link: Path, to_target: Path) -> None:
@@ -16,12 +16,15 @@ from dev_env_scripts_constants.claude_account_constants import (
16
16
  FULL_PERCENT,
17
17
  MAIN_SESSION_USED_CEILING_PERCENT,
18
18
  MAIN_WEEKLY_USED_CEILING_PERCENT,
19
+ PROFILES_ROOT_ENVIRONMENT_VARIABLE,
19
20
  SECOND_SESSION_USED_CEILING_PERCENT,
20
21
  SECOND_WEEKLY_USED_CEILING_PERCENT,
21
22
  )
22
23
  from dev_env_scripts_constants.codex_account_constants import (
23
24
  ALL_PRO_PLAN_TYPES,
25
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
24
26
  CODEX_HOME_ENVIRONMENT_VARIABLE,
27
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
25
28
  LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
26
29
  LUNA_TIER_STOP_PERCENT_LEFT,
27
30
  MAIN_CODEX_HOME_DIRECTORY_NAME,
@@ -30,6 +33,7 @@ from dev_env_scripts_constants.codex_account_constants import (
30
33
  TIER_NORMAL,
31
34
  )
32
35
  from dev_env_scripts_constants.shared_tree_constants import (
36
+ CLAUDE_CONFIG_DIR_ENV_VAR,
33
37
  CODEX_CLASSIFIER_CONSTANTS_RELATIVE_PATH,
34
38
  CONSTANTS_PACKAGE_ANCHOR_DEPTH,
35
39
  PR_LOOP_DIRECTORY_NAME,
@@ -64,6 +68,14 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
64
68
  BROKER_STATE_FILE_NAME = "state.json"
65
69
  BROKER_STATE_TEMP_SUFFIX = ".json"
66
70
  BROKER_STATE_LOCK_SUFFIX = ".lock"
71
+ ALL_ACCOUNT_ENVIRONMENT_VARIABLES = (
72
+ CLAUDE_CONFIG_DIR_ENV_VAR,
73
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
74
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
75
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
76
+ PROFILES_ROOT_ENVIRONMENT_VARIABLE,
77
+ )
78
+ """Variables that point the account tools at a developer's own account roots, Codex home and Codex roster."""
67
79
  ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
68
80
  CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
69
81
  CLAUDE_BINARY_NAME = "claude"
@@ -15,7 +15,9 @@ from pathlib import Path
15
15
  import pytest
16
16
 
17
17
  from dev_env_scripts_constants import account_broker_constants as broker_constants
18
+ from dev_env_scripts_constants import claude_account_constants, codex_account_constants, shared_tree_constants
18
19
  from dev_env_scripts_constants.account_broker_constants import (
20
+ ALL_ACCOUNT_ENVIRONMENT_VARIABLES,
19
21
  ALL_BATCH_FILE_EXTENSIONS,
20
22
  ALL_CLAUDE_FLOORS,
21
23
  ALL_CODEX_FLOORS,
@@ -57,6 +59,16 @@ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
57
59
  }
58
60
 
59
61
 
62
+ def test_should_list_every_account_environment_variable() -> None:
63
+ all_declared_variables = {
64
+ each_value
65
+ for each_module in (claude_account_constants, codex_account_constants, shared_tree_constants)
66
+ for each_name, each_value in vars(each_module).items()
67
+ if each_name.endswith(("_ENVIRONMENT_VARIABLE", "_ENV_VAR"))
68
+ }
69
+ assert all_declared_variables == set(ALL_ACCOUNT_ENVIRONMENT_VARIABLES)
70
+
71
+
60
72
  def test_batch_command_boundaries_include_shell_metacharacters() -> None:
61
73
  assert ALL_BATCH_FILE_EXTENSIONS == frozenset({".bat", ".cmd"})
62
74
  assert set(CMD_SHELL_METACHARACTERS) == set('&|<>^%!"\r\n')
@@ -42,12 +42,6 @@ def _freeze_clock(monkeypatch: pytest.MonkeyPatch, instant: datetime) -> None:
42
42
  monkeypatch.setattr(account_broker, "datetime", _FrozenClock)
43
43
 
44
44
 
45
- @pytest.fixture(autouse=True)
46
- def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
47
- monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
48
- monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
49
-
50
-
51
45
  def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
52
46
  return Account(product, name, Path("/profiles") / name, main, name)
53
47
 
@@ -256,8 +256,7 @@ def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
256
256
  return all_delays
257
257
 
258
258
 
259
- def _first_reading(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, account: Account, read_meters: object) -> object:
260
- monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
259
+ def _first_reading(account: Account, read_meters: object) -> object:
261
260
  adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
262
261
  state = support._load_state(support.broker_state_path())
263
262
  now = datetime(2026, 10, 3, tzinfo=timezone.utc)
@@ -272,7 +271,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
272
271
  all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
273
272
  all_delays = _recorded_sleeps(monkeypatch)
274
273
 
275
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
274
+ reading = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
276
275
 
277
276
  assert reading.meters is not None
278
277
  assert reading.meters.weekly_percent_left == 70.0
@@ -289,7 +288,7 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
289
288
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
290
289
  all_delays = _recorded_sleeps(monkeypatch)
291
290
 
292
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
291
+ reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
293
292
 
294
293
  assert reading.meters is not None
295
294
  assert reading.meters.weekly_percent_left == 70.0
@@ -307,7 +306,7 @@ def test_should_stop_at_once_when_no_codex_executable_is_found(
307
306
  monkeypatch.setattr(Path, "home", lambda: tmp_path)
308
307
  all_delays = _recorded_sleeps(monkeypatch)
309
308
 
310
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
309
+ reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
311
310
 
312
311
  assert reading.meters is None
313
312
  assert reading.unread_reason == "no codex executable on PATH or at its install path"
@@ -322,7 +321,7 @@ def test_should_keep_the_unread_reason_after_every_claude_read_fails(
322
321
  all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
323
322
  all_delays = _recorded_sleeps(monkeypatch)
324
323
 
325
- reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
324
+ reading, adapter, state, now = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
326
325
  cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
327
326
 
328
327
  assert reading.meters is None
@@ -340,7 +339,7 @@ def test_should_keep_the_unread_reason_after_every_codex_read_fails(
340
339
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
341
340
  _recorded_sleeps(monkeypatch)
342
341
 
343
- reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
342
+ reading, adapter, state, now = _first_reading(Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
344
343
  cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
345
344
 
346
345
  assert reading.meters is None
@@ -1,4 +1,4 @@
1
- """Broker state written during a test stays out of the home directory."""
1
+ """Broker state written during a test lands in the per-test home directory."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -18,14 +18,11 @@ from dev_env_scripts_constants.account_broker_constants import (
18
18
  )
19
19
 
20
20
 
21
- def test_meter_cache_write_lands_outside_the_home_directory(
21
+ def test_meter_cache_write_lands_in_the_isolated_home(
22
22
  monkeypatch: pytest.MonkeyPatch,
23
23
  tmp_path: Path,
24
- isolated_broker_state_path: Path,
24
+ isolated_home: Path,
25
25
  ) -> None:
26
- fake_home = tmp_path / "home"
27
- monkeypatch.setenv("HOME", str(fake_home))
28
- monkeypatch.setenv("USERPROFILE", str(fake_home))
29
26
  account = Account(Product.CLAUDE, "only", tmp_path / "only")
30
27
  reset_at = datetime(2026, 10, 5, tzinfo=timezone.utc)
31
28
  adapter = ProductAdapter(
@@ -39,10 +36,12 @@ def test_meter_cache_write_lands_outside_the_home_directory(
39
36
  def runner(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
40
37
  return subprocess.CompletedProcess(argv, 0, "", "")
41
38
 
39
+ isolated_broker_state_path = account_broker.broker_state_path()
40
+ assert Path.home() == isolated_home
41
+ assert isolated_broker_state_path.is_relative_to(isolated_home)
42
+ assert not isolated_broker_state_path.exists()
42
43
  with account_broker.override_subprocess_runner(runner):
43
44
  account_broker.run_job(Product.CLAUDE, ["claude"])
44
45
 
45
- home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
46
- assert not home_state_path.exists()
47
46
  state = json.loads(isolated_broker_state_path.read_text(encoding="utf-8"))
48
47
  assert f"claude:only:{account.home}" in state["meters"]
@@ -0,0 +1,54 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import account_broker_support
10
+ from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def exported_account_variables(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
15
+ exported_root = tmp_path_factory.mktemp("exported-overrides")
16
+ with pytest.MonkeyPatch.context() as module_monkeypatch:
17
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
18
+ module_monkeypatch.setenv(each_account_variable, str(exported_root / each_account_variable))
19
+ yield
20
+
21
+
22
+ def test_broker_state_path_follows_the_redirected_home(
23
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
24
+ ) -> None:
25
+ assert Path.home() == isolated_home
26
+ assert account_broker_support.broker_state_path().is_relative_to(isolated_home)
27
+ moved_home = tmp_path / "moved-home"
28
+ monkeypatch.setenv("HOME", str(moved_home))
29
+ monkeypatch.setenv("USERPROFILE", str(moved_home))
30
+ assert account_broker_support.broker_state_path().is_relative_to(moved_home)
31
+
32
+
33
+ def test_isolated_home_clears_the_exported_account_variables(
34
+ exported_account_variables: None, isolated_home: Path
35
+ ) -> None:
36
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
37
+ assert each_account_variable not in os.environ
38
+
39
+
40
+ def test_isolated_home_keeps_the_python_user_base_for_a_new_interpreter(
41
+ isolated_home: Path,
42
+ ) -> None:
43
+ completed = subprocess.run(
44
+ [sys.executable, "-c", "import site; print(site.getuserbase())"],
45
+ check=False,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+
50
+ assert completed.returncode == 0
51
+ child_user_base = Path(completed.stdout.strip())
52
+ assert str(child_user_base) == os.environ["PYTHONUSERBASE"]
53
+ assert child_user_base != isolated_home
54
+ assert isolated_home not in child_user_base.parents