claude-dev-env 8.84.0 → 8.85.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.
@@ -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')
@@ -130,6 +130,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
130
130
  hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
131
131
  hooks/blocking/step_note_gate.py -> exempt
132
132
  hooks/blocking/reply_length_gate.py -> exempt
133
+ hooks/blocking/chat_reply_gate.py -> exempt
133
134
  hooks/blocking/edit_marker_gate.py -> exempt
134
135
  hooks/blocking/issue_close_handoff_gate.py -> exempt
135
136
  hooks/blocking/verify_before_acting.py -> exempt
@@ -154,7 +155,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
154
155
  It asks for a readable status line and decides no code or safety policy.
155
156
 
156
157
  The reply length gate caps the sentences in a chat reply to the user. It
157
- decides the shape of prose and no code or safety policy.
158
+ decides the shape of prose and no code or safety policy. The chat reply
159
+ gate runs the same checks on a reply typed straight into the chat.
158
160
 
159
161
  The edit marker gate keeps strikethrough and edit notes out of an edited
160
162
  chat message. It decides the shape of prose and no code or safety policy.
@@ -67,6 +67,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
67
67
  "blocking/merge_review_gate.py",
68
68
  "blocking/step_note_gate.py",
69
69
  "blocking/reply_length_gate.py",
70
+ "blocking/chat_reply_gate.py",
70
71
  "blocking/edit_marker_gate.py",
71
72
  "blocking/issue_close_handoff_gate.py",
72
73
  "blocking/verify_before_acting.py",
@@ -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
@@ -90,6 +90,14 @@ def test_new_reply_length_gate_registration_is_clean(tmp_path: Path) -> None:
90
90
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
91
91
 
92
92
 
93
+ def test_new_chat_reply_gate_registration_is_clean(tmp_path: Path) -> None:
94
+ current_document = _hook_document(
95
+ ["hooks/blocking/chat_reply_gate.py"],
96
+ '{"hooks": {}}',
97
+ )
98
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
99
+
100
+
93
101
  def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
94
102
  current_document = _hook_document(
95
103
  ["hooks/blocking/edit_marker_gate.py"],