claude-dev-env 8.48.3 → 8.48.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.48.3",
3
+ "version": "8.48.5",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -607,6 +607,7 @@ def read_pull_request(slug: str, number: int, token: str) -> Mapping[str, object
607
607
  def read_blocked_evidence(
608
608
  slug: str,
609
609
  all_pull_request_fields: Mapping[str, object],
610
+ all_rules: list[object] | MergeCheckError,
610
611
  token: str,
611
612
  ) -> BlockedEvidence:
612
613
  """Read what stands behind a ``blocked`` merge state.
@@ -615,6 +616,8 @@ def read_blocked_evidence(
615
616
  slug: The repository as ``owner/name``.
616
617
  all_pull_request_fields: The pull request, carrying its base branch
617
618
  and its head commit.
619
+ all_rules: The base branch rules, or the failure that kept them
620
+ unread.
618
621
  token: The GitHub token the requests authenticate with.
619
622
 
620
623
  Returns:
@@ -628,7 +631,8 @@ def read_blocked_evidence(
628
631
  str(_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY))
629
632
  )
630
633
  head_sha = str(_nested_field(all_pull_request_fields, HEAD_KEY, SHA_KEY))
631
- all_rules = _read_branch_rules(slug, base_ref, token)
634
+ if isinstance(all_rules, MergeCheckError):
635
+ raise all_rules
632
636
  return BlockedEvidence(
633
637
  all_unmet_checks=_read_unmet_checks(slug, head_sha, all_rules, token),
634
638
  behind_by=_read_behind_by(slug, base_ref, head_sha, token),
@@ -636,13 +640,30 @@ def read_blocked_evidence(
636
640
  )
637
641
 
638
642
 
639
- def _read_branch_rules(slug: str, base_ref: str, token: str) -> list[object]:
640
- return _request_list(
641
- BRANCH_RULES_ENDPOINT_TEMPLATE.format(
642
- api_root=GITHUB_API_ROOT, slug=slug, branch=base_ref
643
- ),
644
- token,
645
- )
643
+ def read_branch_rules(
644
+ slug: str, base_ref: str, token: str
645
+ ) -> list[object] | MergeCheckError:
646
+ """Read the rules GitHub reports as active on the base branch.
647
+
648
+ Args:
649
+ slug: The repository as ``owner/name``.
650
+ base_ref: The branch the pull request merges into.
651
+ token: The GitHub token the request authenticates with.
652
+
653
+ Returns:
654
+ The rules, or the failure that kept them unread.
655
+ """
656
+ try:
657
+ return _request_list(
658
+ BRANCH_RULES_ENDPOINT_TEMPLATE.format(
659
+ api_root=GITHUB_API_ROOT,
660
+ slug=slug,
661
+ branch=urllib.parse.quote(base_ref),
662
+ ),
663
+ token,
664
+ )
665
+ except MergeCheckError as failure:
666
+ return failure
646
667
 
647
668
 
648
669
  def read_unstable_checks(
@@ -879,7 +900,11 @@ def _field_at(document: object, all_fields: object, key: str) -> object:
879
900
 
880
901
 
881
902
  def read_merge_queue_ejection(
882
- slug: str, number: int, base_ref: str, token: str
903
+ slug: str,
904
+ number: int,
905
+ base_ref: str,
906
+ all_rules: list[object] | MergeCheckError,
907
+ token: str,
883
908
  ) -> bool:
884
909
  """Read whether the merge queue ejected the pull request's current head.
885
910
 
@@ -891,6 +916,8 @@ def read_merge_queue_ejection(
891
916
  slug: The repository as ``owner/name``.
892
917
  number: The pull request number.
893
918
  base_ref: The branch the pull request merges into.
919
+ all_rules: The base branch rules, or the failure that kept them
920
+ unread.
894
921
  token: The GitHub token the request authenticates with.
895
922
 
896
923
  Returns:
@@ -901,7 +928,9 @@ def read_merge_queue_ejection(
901
928
  MergeCheckError: A read failed, or answered with another shape. A
902
929
  refused GraphQL query names the merge queue that needed it.
903
930
  """
904
- if not _may_merge_through_a_queue(slug, base_ref, token):
931
+ if not isinstance(all_rules, MergeCheckError) and not has_merge_queue_rule(
932
+ all_rules
933
+ ):
905
934
  return False
906
935
  document = _merge_queue_removal_document(slug, number, base_ref, token)
907
936
  return _is_ejected_on_head(
@@ -924,14 +953,6 @@ def _merge_queue_removal_document(
924
953
  ) from failure
925
954
 
926
955
 
927
- def _may_merge_through_a_queue(slug: str, base_ref: str, token: str) -> bool:
928
- try:
929
- all_rules = _read_branch_rules(slug, urllib.parse.quote(base_ref), token)
930
- except MergeCheckError:
931
- return True
932
- return has_merge_queue_rule(all_rules)
933
-
934
-
935
956
  def _list_along(document: object, all_keys: Sequence[str]) -> list[object]:
936
957
  all_records = functools.reduce(
937
958
  functools.partial(_field_at, document), all_keys, document
@@ -1009,11 +1030,10 @@ def main(all_arguments: Sequence[str]) -> int:
1009
1030
  unresolved_thread_count = read_unresolved_thread_count(
1010
1031
  parsed.slug, parsed.number, token
1011
1032
  )
1033
+ base_ref = str(_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY))
1034
+ all_rules = read_branch_rules(parsed.slug, base_ref, token)
1012
1035
  is_ejected_from_merge_queue = read_merge_queue_ejection(
1013
- parsed.slug,
1014
- parsed.number,
1015
- str(_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY)),
1016
- token,
1036
+ parsed.slug, parsed.number, base_ref, all_rules, token
1017
1037
  )
1018
1038
  except MergeCheckError as failure:
1019
1039
  print(failure, file=sys.stderr)
@@ -1022,7 +1042,7 @@ def main(all_arguments: Sequence[str]) -> int:
1022
1042
  if all_pull_request_fields.get(MERGEABLE_STATE_KEY) == MERGEABLE_STATE_BLOCKED:
1023
1043
  try:
1024
1044
  blocked_evidence = read_blocked_evidence(
1025
- parsed.slug, all_pull_request_fields, token
1045
+ parsed.slug, all_pull_request_fields, all_rules, token
1026
1046
  )
1027
1047
  except MergeCheckError as failure:
1028
1048
  print(failure, file=sys.stderr)
@@ -28,11 +28,31 @@ class WeeklyUtilizationProbeError(Exception):
28
28
  """Raised when an account's usage meters cannot be measured."""
29
29
 
30
30
 
31
- def _usage_pause_scripts_directory() -> Path:
32
- package_root = Path(__file__).resolve().parent.parent
33
- return (
31
+ def _usage_pause_scripts_directory(module_file: Path) -> Path:
32
+ """Return the usage-pause scripts directory beside this scripts tree.
33
+
34
+ ::
35
+
36
+ packages/claude-dev-env/scripts/x.py -> packages/claude-dev-env/.agents/skills/usage-pause/scripts
37
+ ~/.claude/scripts/x.py (link) -> ~/.agents/skills/usage-pause/scripts
38
+
39
+ The installer links ~/.claude/scripts to ~/.agents/scripts, so the
40
+ resolved scripts tree already sits inside the agents home.
41
+
42
+ Args:
43
+ module_file: Path of a module inside the scripts tree.
44
+
45
+ Returns:
46
+ The directory that holds the usage-pause resolver.
47
+ """
48
+ package_root = module_file.resolve().parent.parent
49
+ agents_home = (
34
50
  package_root
35
- / USAGE_PAUSE_AGENTS_HOME_DIRECTORY_NAME
51
+ if package_root.name.endswith(USAGE_PAUSE_AGENTS_HOME_DIRECTORY_NAME)
52
+ else package_root / USAGE_PAUSE_AGENTS_HOME_DIRECTORY_NAME
53
+ )
54
+ return (
55
+ agents_home
36
56
  / USAGE_PAUSE_SKILL_DIRECTORY_NAME
37
57
  / USAGE_PAUSE_SKILL_NAME
38
58
  / USAGE_PAUSE_SCRIPTS_DIRECTORY_NAME
@@ -48,7 +68,7 @@ def _load_resolve_usage_window_module() -> ModuleType:
48
68
  already_loaded = sys.modules.get(RESOLVE_USAGE_WINDOW_MODULE_NAME)
49
69
  if already_loaded is not None:
50
70
  return already_loaded
51
- scripts_directory = _usage_pause_scripts_directory()
71
+ scripts_directory = _usage_pause_scripts_directory(Path(__file__))
52
72
  module_path = scripts_directory / RESOLVE_USAGE_WINDOW_FILENAME
53
73
  if not module_path.is_file():
54
74
  raise WeeklyUtilizationProbeError(
@@ -90,12 +110,43 @@ class AccountUsageMeters:
90
110
  weekly_resets_at: datetime | None
91
111
 
92
112
 
113
+ def _read_access_token(
114
+ usage_window_resolver: ModuleType, credentials_path: Path, now: datetime
115
+ ) -> str | None:
116
+ """Return the bearer token that reads one account's meters.
117
+
118
+ ::
119
+
120
+ credential file holds a token -> that token
121
+ no token, the session's own credential path -> the session ingress token
122
+ no token, another account's credential path -> None
123
+
124
+ A cloud session has no credential file, only the ingress token, and that
125
+ token belongs to the account the session runs on.
126
+
127
+ Args:
128
+ usage_window_resolver: The loaded usage-pause resolver module.
129
+ credentials_path: The account's CLI credential file.
130
+ now: The current time, for the token expiry check.
131
+
132
+ Returns:
133
+ A bearer token, or None when the account has none.
134
+ """
135
+ file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
136
+ if file_token is not None:
137
+ return file_token
138
+ session_credentials_path = usage_window_resolver.default_credentials_path()
139
+ if credentials_path.resolve() != session_credentials_path.resolve():
140
+ return None
141
+ return usage_window_resolver.read_session_ingress_token()
142
+
143
+
93
144
  def _fetch_account_usage_payload(
94
145
  usage_window_resolver: ModuleType, credentials_path: Path
95
146
  ) -> dict[str, object]:
96
147
  now = datetime.now().astimezone()
97
148
  try:
98
- access_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
149
+ access_token = _read_access_token(usage_window_resolver, credentials_path, now)
99
150
  if access_token is None:
100
151
  raise WeeklyUtilizationProbeError(
101
152
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
@@ -291,10 +291,15 @@ def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> Non
291
291
  "read_unresolved_thread_count",
292
292
  lambda slug, number, token: 0,
293
293
  )
294
+ monkeypatch.setattr(
295
+ agent_merge_check,
296
+ "read_branch_rules",
297
+ lambda slug, base_ref, token: [],
298
+ )
294
299
  monkeypatch.setattr(
295
300
  agent_merge_check,
296
301
  "read_merge_queue_ejection",
297
- lambda slug, number, base_ref, token: False,
302
+ lambda slug, number, base_ref, all_rules, token: False,
298
303
  )
299
304
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
300
305
 
@@ -311,10 +316,15 @@ def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
311
316
  "read_unresolved_thread_count",
312
317
  lambda slug, number, token: 0,
313
318
  )
319
+ monkeypatch.setattr(
320
+ agent_merge_check,
321
+ "read_branch_rules",
322
+ lambda slug, base_ref, token: [],
323
+ )
314
324
  monkeypatch.setattr(
315
325
  agent_merge_check,
316
326
  "read_merge_queue_ejection",
317
- lambda slug, number, base_ref, token: False,
327
+ lambda slug, number, base_ref, all_rules, token: False,
318
328
  )
319
329
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
320
330
 
@@ -491,6 +501,65 @@ def test_an_unreadable_branch_rule_keeps_the_generic_blocked_reason(
491
501
  assert BLOCKED_HOLD_REASON in line
492
502
 
493
503
 
504
+ def test_a_blocked_pull_request_reads_the_branch_rules_once(
505
+ monkeypatch: pytest.MonkeyPatch,
506
+ capsys: pytest.CaptureFixture[str],
507
+ ) -> None:
508
+ routed_answer = _github_answers(
509
+ MAIN_BRANCH_RULES,
510
+ [
511
+ _check_run("Ruff", "success", run_id=11),
512
+ _check_run("Tip Local green", "success", run_id=12),
513
+ ],
514
+ behind_by=225,
515
+ )
516
+ all_rules_urls: list[str] = []
517
+
518
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
519
+ if "/rules/branches/" in url:
520
+ all_rules_urls.append(url)
521
+ return routed_answer(url, token, all_payload_fields)
522
+
523
+ exit_code, line = _run_main(monkeypatch, capsys, _answer)
524
+ assert exit_code == 1
525
+ assert BEHIND_MERGE_QUEUE_HOLD_TEMPLATE.format(count=225) in line
526
+ assert len(all_rules_urls) == 1
527
+
528
+
529
+ def test_read_branch_rules_quotes_the_base_branch_and_returns_its_rules(
530
+ monkeypatch: pytest.MonkeyPatch,
531
+ ) -> None:
532
+ all_rules_urls: list[str] = []
533
+
534
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
535
+ all_rules_urls.append(url)
536
+ return MAIN_BRANCH_RULES
537
+
538
+ monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
539
+ assert (
540
+ agent_merge_check.read_branch_rules(
541
+ "jl-cmd/claude-dev-env", "release/2026 q4", "token"
542
+ )
543
+ == MAIN_BRANCH_RULES
544
+ )
545
+ assert all_rules_urls[0].endswith("/rules/branches/release/2026%20q4")
546
+
547
+
548
+ def test_read_branch_rules_returns_the_failure_of_a_refused_read(
549
+ monkeypatch: pytest.MonkeyPatch,
550
+ ) -> None:
551
+ refusal = agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
552
+
553
+ def _refuse(url: str, token: str, all_payload_fields: object) -> object:
554
+ raise refusal
555
+
556
+ monkeypatch.setattr(agent_merge_check, "_request_json", _refuse)
557
+ assert (
558
+ agent_merge_check.read_branch_rules("jl-cmd/claude-dev-env", "main", "token")
559
+ is refusal
560
+ )
561
+
562
+
494
563
  def _evidence(
495
564
  all_unmet_checks: tuple[str, ...] = (),
496
565
  behind_by: int = 0,
@@ -626,7 +695,7 @@ def test_read_blocked_evidence_reads_the_pull_request_4922_shape(
626
695
  ),
627
696
  )
628
697
  assert agent_merge_check.read_blocked_evidence(
629
- "jl-cmd/claude-dev-env", PULL_REQUEST_4922, "token"
698
+ "jl-cmd/claude-dev-env", PULL_REQUEST_4922, MAIN_BRANCH_RULES, "token"
630
699
  ) == _evidence(behind_by=225)
631
700
 
632
701
 
@@ -821,7 +890,7 @@ def test_read_merge_queue_ejection_compares_failed_checks_removals_to_the_head(
821
890
  )
822
891
  assert (
823
892
  agent_merge_check.read_merge_queue_ejection(
824
- "jl-cmd/claude-dev-env", 1442, "main", "token"
893
+ "jl-cmd/claude-dev-env", 1442, "main", MAIN_BRANCH_RULES, "token"
825
894
  )
826
895
  is expected_ejection
827
896
  )
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib
6
+ import os
5
7
  import subprocess
6
8
  import sys
7
9
  import urllib.error
@@ -44,18 +46,95 @@ def test_probe_reads_both_meters_and_resets(monkeypatch: pytest.MonkeyPatch) ->
44
46
  )
45
47
 
46
48
 
47
- def test_probe_rejects_missing_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
49
+ def test_probe_reads_the_session_token_for_the_session_account(
50
+ monkeypatch: pytest.MonkeyPatch,
51
+ ) -> None:
52
+ session_credentials = Path("home") / ".claude" / ".credentials.json"
53
+ all_fetched_tokens: list[str] = []
54
+
55
+ class Windows:
56
+ session_utilization = 15.0
57
+ session_resets_at = None
58
+ weekly_utilization = 70.0
59
+ weekly_resets_at = None
60
+
48
61
  class Resolver:
49
62
  def read_oauth_access_token(self, credentials_path: Path, now: datetime) -> None:
50
63
  return None
51
64
 
65
+ def default_credentials_path(self) -> Path:
66
+ return session_credentials
67
+
68
+ def read_session_ingress_token(self) -> str:
69
+ return "session-token"
70
+
71
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
72
+ all_fetched_tokens.append(access_token)
73
+ return {"usage": 70.0}
74
+
75
+ def extract_usage_windows(self, payload: dict[str, object]) -> Windows:
76
+ return Windows()
77
+
78
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: Resolver())
79
+
80
+ assert usage.probe_account_meters(session_credentials) == usage.AccountUsageMeters(
81
+ 15.0, None, 70.0, None
82
+ )
83
+ assert all_fetched_tokens == ["session-token"]
84
+
85
+
86
+ def test_probe_rejects_missing_access_token_for_another_account(
87
+ monkeypatch: pytest.MonkeyPatch,
88
+ ) -> None:
89
+ class Resolver:
90
+ def read_oauth_access_token(self, credentials_path: Path, now: datetime) -> None:
91
+ return None
92
+
93
+ def default_credentials_path(self) -> Path:
94
+ return Path("home") / ".claude" / ".credentials.json"
95
+
96
+ def read_session_ingress_token(self) -> str:
97
+ return "session-token"
98
+
52
99
  def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
53
100
  pytest.fail("meter request must not run")
54
101
 
55
102
  monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: Resolver())
56
103
 
57
104
  with pytest.raises(usage.WeeklyUtilizationProbeError, match="bearer token"):
58
- usage.probe_account_meters(Path("account.json"))
105
+ usage.probe_account_meters(Path("home") / ".claude-profiles" / "ev" / ".credentials.json")
106
+
107
+
108
+ def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
109
+ if sys.platform.startswith("win32"):
110
+ importlib.import_module("_winapi").CreateJunction(str(to_target), str(from_link))
111
+ return
112
+ os.symlink(to_target, from_link, target_is_directory=True)
113
+
114
+
115
+ def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path) -> None:
116
+ home = tmp_path.resolve()
117
+ (home / ".agents" / "scripts").mkdir(parents=True)
118
+ (home / ".claude").mkdir()
119
+ _create_directory_link(
120
+ from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
121
+ )
122
+
123
+ scripts_directory = usage._usage_pause_scripts_directory(
124
+ home / ".claude" / "scripts" / "claude_chain_usage.py"
125
+ )
126
+
127
+ assert scripts_directory == home / ".agents" / "skills" / "usage-pause" / "scripts"
128
+
129
+
130
+ def test_scripts_directory_in_the_package_tree(tmp_path: Path) -> None:
131
+ package_root = tmp_path.resolve() / "claude-dev-env"
132
+
133
+ scripts_directory = usage._usage_pause_scripts_directory(
134
+ package_root / "scripts" / "claude_chain_usage.py"
135
+ )
136
+
137
+ assert scripts_directory == package_root / ".agents" / "skills" / "usage-pause" / "scripts"
59
138
 
60
139
 
61
140
  def test_probe_wraps_network_failure(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -79,7 +158,7 @@ def test_failed_module_load_does_not_poison_cache(
79
158
  (tmp_path / "resolve_usage_window.py").write_text(
80
159
  "raise ImportError('intentional load failure')\n", encoding="utf-8"
81
160
  )
82
- monkeypatch.setattr(usage, "_usage_pause_scripts_directory", lambda: tmp_path)
161
+ monkeypatch.setattr(usage, "_usage_pause_scripts_directory", lambda _module_file: tmp_path)
83
162
 
84
163
  with pytest.raises(ImportError, match="intentional load failure"):
85
164
  usage._load_resolve_usage_window_module()