claude-dev-env 8.75.3 → 8.75.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.
@@ -1,16 +1,23 @@
1
1
  """Behavioral tests for the artifact template pointer's path."""
2
2
 
3
- from hooks_constants.artifact_template_pointer_constants import (
4
- ARTIFACT_TEMPLATE_PATH,
5
- HTML_PLAN_TEMPLATE_README_PATH,
6
- )
3
+ from hooks_constants import artifact_template_pointer_constants
7
4
 
8
5
 
9
6
  def test_template_path_names_the_packaged_template_file() -> None:
10
- assert ARTIFACT_TEMPLATE_PATH.is_file()
11
- assert ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
7
+ assert artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH.is_file()
8
+ assert artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
12
9
 
13
10
 
14
11
  def test_html_plan_readme_path_names_the_packaged_readme_and_house_stylesheet() -> None:
15
- assert HTML_PLAN_TEMPLATE_README_PATH.is_file()
16
- assert HTML_PLAN_TEMPLATE_README_PATH.parent.joinpath("house.css").is_file()
12
+ assert artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH.is_file()
13
+ assert artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH.parent.joinpath("house.css").is_file()
14
+
15
+
16
+ def test_every_exported_name_resolves_to_a_module_attribute() -> None:
17
+ all_missing_names = [
18
+ each_name
19
+ for each_name in artifact_template_pointer_constants.__all__
20
+ if not hasattr(artifact_template_pointer_constants, each_name)
21
+ ]
22
+
23
+ assert all_missing_names == []
@@ -0,0 +1,61 @@
1
+ """Behavioral tests for the image_read_size_gate header offsets and refusal text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from image_read_size_gate import image_size
8
+
9
+ from hooks_constants.image_read_size_gate_constants import (
10
+ AGENT_COPY_SCRIPT_PATH,
11
+ ALL_GIF_SIGNATURES,
12
+ JPEG_MARKER_PREFIX,
13
+ JPEG_SIGNATURE,
14
+ MAXIMUM_LONG_EDGE_PIXELS,
15
+ OVERSIZED_IMAGE_REASON_TEMPLATE,
16
+ PNG_SIGNATURE,
17
+ )
18
+
19
+ BASELINE_FRAME_MARKER = 0xC0
20
+ SAMPLE_PRECISION = 8
21
+
22
+
23
+ def test_png_offsets_read_the_width_and_height_from_the_ihdr_chunk() -> None:
24
+ ihdr_length = struct.pack(">I", 13)
25
+ png_header = PNG_SIGNATURE + ihdr_length + b"IHDR" + struct.pack(">II", 1440, 2560)
26
+
27
+ assert image_size(png_header) == (1440, 2560)
28
+
29
+
30
+ def test_gif_offsets_read_the_little_endian_logical_screen_size() -> None:
31
+ for each_signature in ALL_GIF_SIGNATURES:
32
+ gif_header = each_signature + struct.pack("<HH", 640, 480)
33
+
34
+ assert image_size(gif_header) == (640, 480)
35
+
36
+
37
+ def test_jpeg_frame_offsets_read_height_then_width_from_a_baseline_frame() -> None:
38
+ frame_segment = bytes([JPEG_MARKER_PREFIX, BASELINE_FRAME_MARKER]) + struct.pack(
39
+ ">HBHH", 17, SAMPLE_PRECISION, 2560, 1440
40
+ )
41
+
42
+ assert image_size(JPEG_SIGNATURE + frame_segment) == (1440, 2560)
43
+
44
+
45
+ def test_agent_copy_script_path_names_the_packaged_script() -> None:
46
+ assert AGENT_COPY_SCRIPT_PATH.is_file()
47
+ assert AGENT_COPY_SCRIPT_PATH.name == "agent_image_copy.py"
48
+
49
+
50
+ def test_reason_template_names_the_size_the_cap_and_the_copy_command() -> None:
51
+ reason = OVERSIZED_IMAGE_REASON_TEMPLATE.format(
52
+ width=1500,
53
+ height=2700,
54
+ maximum_edge=MAXIMUM_LONG_EDGE_PIXELS,
55
+ script_path=AGENT_COPY_SCRIPT_PATH,
56
+ image_path="/tmp/shot.png",
57
+ )
58
+
59
+ assert "1500x2700" in reason
60
+ assert f"{MAXIMUM_LONG_EDGE_PIXELS} pixels" in reason
61
+ assert f'python3 "{AGENT_COPY_SCRIPT_PATH}" "/tmp/shot.png"' in reason
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from unittest.mock import patch
6
7
 
7
8
  import pytest
8
9
 
9
10
  from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
11
+ from hooks_constants.test_stdout_flush_support import FlushRecordingStream
10
12
 
11
13
 
12
14
  def test_should_write_the_post_tool_use_payload_carrying_the_context_text(
@@ -20,3 +22,14 @@ def test_should_write_the_post_tool_use_payload_carrying_the_context_text(
20
22
  "additionalContext": "=== PR DONE CHECKLIST ===",
21
23
  }
22
24
  }
25
+
26
+
27
+ def test_should_flush_the_whole_payload_so_the_dispatcher_reads_it_before_exit() -> None:
28
+ recording_stream = FlushRecordingStream()
29
+ with patch("sys.stdout", recording_stream):
30
+ write_post_tool_use_context_to_stdout('line one\nline "two"')
31
+
32
+ assert recording_stream.flushed_text
33
+ assert json.loads(recording_stream.flushed_text[-1])["hookSpecificOutput"]["additionalContext"] == (
34
+ 'line one\nline "two"'
35
+ )
@@ -7,6 +7,7 @@ from io import StringIO
7
7
  from unittest.mock import patch
8
8
 
9
9
  from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
10
+ from hooks_constants.test_stdout_flush_support import FlushRecordingStream
10
11
 
11
12
 
12
13
  def _emitted_payload(updated_tool_input: dict[str, object]) -> dict[str, object]:
@@ -25,3 +26,13 @@ def test_emitter_writes_an_allow_decision_carrying_the_updated_input() -> None:
25
26
  "updatedInput": {"command": "git status", "description": "check"},
26
27
  }
27
28
  }
29
+
30
+
31
+ def test_emitter_flushes_the_whole_payload_with_nested_input_intact() -> None:
32
+ nested_input = {"command": "echo hi", "options": {"timeout": 5, "flags": ["-n"]}}
33
+ recording_stream = FlushRecordingStream()
34
+ with patch("sys.stdout", recording_stream):
35
+ write_pre_tool_use_allow_to_stdout(nested_input)
36
+
37
+ assert recording_stream.flushed_text
38
+ assert json.loads(recording_stream.flushed_text[-1])["hookSpecificOutput"]["updatedInput"] == nested_input
@@ -0,0 +1,17 @@
1
+ """Shared stdout stand-in for hook emitter tests that check flush behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from io import StringIO
6
+
7
+
8
+ class FlushRecordingStream(StringIO):
9
+ """StringIO that records the full buffer text at each flush call."""
10
+
11
+ def __init__(self) -> None:
12
+ super().__init__()
13
+ self.flushed_text: list[str] = []
14
+
15
+ def flush(self) -> None:
16
+ self.flushed_text.append(self.getvalue())
17
+ super().flush()
@@ -1,3 +1,6 @@
1
+ import json
2
+ import re
3
+
1
4
  from hooks_constants import visual_reply_rules_constants as constants
2
5
 
3
6
 
@@ -13,3 +16,28 @@ def test_tracker_number_pattern_reads_items_issues_and_cards() -> None:
13
16
  assert [
14
17
  each.group(0) for each in constants.TRACKER_NUMBER_PATTERN.finditer("issue 5735, item #6, card 2")
15
18
  ] == ["issue 5735", "item #6", "card 2"]
19
+
20
+
21
+ def test_rule_key_constants_name_rules_in_the_shipped_rule_list() -> None:
22
+ rules_document = json.loads(constants.RULES_FILE_PATH.read_text(encoding=constants.RULES_FILE_ENCODING))
23
+ all_shipped_keys = {each_rule[constants.RULE_KEY_FIELD] for each_rule in rules_document[constants.RULES_LIST_KEY]}
24
+
25
+ assert {
26
+ constants.NO_PICTURE_RULE_KEY,
27
+ constants.ABBREVIATIONS_RULE_KEY,
28
+ constants.ONE_ITEM_RULE_KEY,
29
+ } <= all_shipped_keys
30
+ assert constants.ALLOWED_CAPITALIZED_WORDS_KEY in rules_document
31
+ assert constants.LOWERCASE_ABBREVIATIONS_KEY in rules_document
32
+
33
+
34
+ def test_lowercase_abbreviation_template_matches_the_word_on_its_own_only() -> None:
35
+ pattern = re.compile(constants.LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape("repo")))
36
+
37
+ assert pattern.findall("the repo, my repos, a repo2 tag") == ["repo"]
38
+
39
+
40
+ def test_widget_anchor_pattern_matches_a_link_and_skips_other_tags() -> None:
41
+ assert constants.WIDGET_ANCHOR_PATTERN.search('<A class="x" HREF="https://example.com">')
42
+ assert constants.WIDGET_ANCHOR_PATTERN.search('<abbr title="a">') is None
43
+ assert constants.WIDGET_ANCHOR_PATTERN.search("<a>no link</a>") is None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.75.3",
3
+ "version": "8.75.5",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,15 +29,19 @@ from claude_account_profile import default_profile_home, validate_profile_name
29
29
  from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
30
30
  from dev_env_scripts_constants.account_broker_constants import (
31
31
  ALL_BATCH_FILE_EXTENSIONS,
32
+ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS,
33
+ ALL_LOGIN_REFRESH_ARGUMENTS,
32
34
  BROKER_STATE_DIRECTORY_NAME,
33
35
  BROKER_STATE_FILE_NAME,
34
36
  BROKER_STATE_LOCK_SUFFIX,
35
37
  BROKER_STATE_TEMP_SUFFIX,
36
38
  Account,
37
39
  BrokerConfigurationError,
40
+ CLAUDE_BINARY_NAME,
38
41
  CMD_SHELL_METACHARACTERS,
39
42
  Decision,
40
43
  JobOutcome,
44
+ LOGIN_REFRESH_TIMEOUT_SECONDS,
41
45
  METER_READ_ATTEMPTS,
42
46
  METER_READ_DELAY_FACTOR,
43
47
  METER_READ_FIRST_DELAY_SECONDS,
@@ -208,6 +212,54 @@ def load_codex_accounts() -> tuple[Account, ...]:
208
212
  return tuple(Account(Product.CODEX, name, (profiles_root / name).resolve()) for name in names)
209
213
 
210
214
 
215
+ def resolve_claude_path() -> Path | None:
216
+ """Find the Claude Code CLI.
217
+
218
+ Returns:
219
+ ``claude`` on PATH, else a known install path, else None.
220
+ """
221
+ on_path = shutil.which(CLAUDE_BINARY_NAME)
222
+ if on_path is not None:
223
+ return Path(on_path)
224
+ for each_parts in ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS:
225
+ candidate = Path.home().joinpath(*each_parts)
226
+ if candidate.is_file():
227
+ return candidate
228
+ return None
229
+
230
+
231
+ def refresh_claude_login(account_home: Path) -> bool:
232
+ """Run one small CLI call so the CLI refreshes an expired login.
233
+
234
+ ::
235
+
236
+ ev token expired -> claude -p ok, CLAUDE_CONFIG_DIR=<ev home> -> new token saved
237
+
238
+ The CLI saves a new access token before it sends the request.
239
+
240
+ Args:
241
+ account_home: The account's Claude config folder.
242
+
243
+ Returns:
244
+ True when the call ran, False when no CLI was found or the call could not start.
245
+ """
246
+ claude_path = resolve_claude_path()
247
+ if claude_path is None:
248
+ return False
249
+ refresh_environment = {**os.environ, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
250
+ try:
251
+ subprocess.run(
252
+ [str(claude_path), *ALL_LOGIN_REFRESH_ARGUMENTS],
253
+ env=refresh_environment,
254
+ capture_output=True,
255
+ timeout=LOGIN_REFRESH_TIMEOUT_SECONDS,
256
+ check=False,
257
+ )
258
+ except (OSError, subprocess.SubprocessError):
259
+ return False
260
+ return True
261
+
262
+
211
263
  def read_claude_meters(account: Account) -> Meters | None:
212
264
  """Probe one Claude account.
213
265
 
@@ -221,7 +273,7 @@ def read_claude_meters(account: Account) -> Meters | None:
221
273
  WeeklyUtilizationProbeError: The usage meters could not be measured.
222
274
  OSError: The credentials file could not be read.
223
275
  """
224
- usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
276
+ usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME, refresh_claude_login)
225
277
  return Meters(
226
278
  FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
227
279
  usage.session_resets_at,
@@ -216,10 +216,20 @@ def _is_inside(candidate_text: str, directory: Path) -> bool:
216
216
  return _comparable_path(candidate_text).startswith(comparable_directory + os.sep)
217
217
 
218
218
 
219
+ def _link_target_is_present(link_path: Path) -> bool:
220
+ try:
221
+ link_path.stat()
222
+ except PermissionError:
223
+ return True
224
+ except OSError:
225
+ return False
226
+ return True
227
+
228
+
219
229
  def _unlink_orphaned_links(main_home: Path, profile_home: Path) -> tuple[str, ...]:
220
230
  all_unlinked: list[str] = []
221
231
  for each_entry in sorted(profile_home.iterdir()):
222
- if not _is_link(each_entry) or each_entry.exists():
232
+ if not _is_link(each_entry) or _link_target_is_present(each_entry):
223
233
  continue
224
234
  if not _is_inside(_link_target_text(each_entry), main_home):
225
235
  continue
@@ -10,6 +10,7 @@ from dataclasses import dataclass
10
10
  from datetime import datetime
11
11
  from pathlib import Path
12
12
  from types import ModuleType
13
+ from typing import Callable
13
14
 
14
15
  from dev_env_scripts_constants.claude_chain_usage_constants import (
15
16
  NO_ACCESS_TOKEN_ERROR_TEMPLATE,
@@ -111,13 +112,17 @@ class AccountUsageMeters:
111
112
 
112
113
 
113
114
  def _read_access_token(
114
- usage_window_resolver: ModuleType, credentials_path: Path, now: datetime
115
+ usage_window_resolver: ModuleType,
116
+ credentials_path: Path,
117
+ now: datetime,
118
+ refresh_login: Callable[[Path], object] | None = None,
115
119
  ) -> str | None:
116
120
  """Return the bearer token that reads one account's meters.
117
121
 
118
122
  ::
119
123
 
120
124
  credential file holds a token -> that token
125
+ token expired, refresh_login saves a new one -> the new token
121
126
  no token, the session's own credential path -> the session ingress token
122
127
  no token, another account's credential path -> None
123
128
 
@@ -128,6 +133,8 @@ def _read_access_token(
128
133
  usage_window_resolver: The loaded usage-pause resolver module.
129
134
  credentials_path: The account's CLI credential file.
130
135
  now: The current time, for the token expiry check.
136
+ refresh_login: Called with the account home when the stored token is
137
+ unusable, so a fresh token can be saved before one more read.
131
138
 
132
139
  Returns:
133
140
  A bearer token, or None when the account has none.
@@ -135,6 +142,11 @@ def _read_access_token(
135
142
  file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
136
143
  if file_token is not None:
137
144
  return file_token
145
+ if refresh_login is not None and credentials_path.is_file():
146
+ refresh_login(credentials_path.parent)
147
+ refreshed_token = usage_window_resolver.read_oauth_access_token(credentials_path, datetime.now().astimezone())
148
+ if refreshed_token is not None:
149
+ return refreshed_token
138
150
  session_credentials_path = usage_window_resolver.default_credentials_path()
139
151
  if credentials_path.resolve() != session_credentials_path.resolve():
140
152
  return None
@@ -142,11 +154,13 @@ def _read_access_token(
142
154
 
143
155
 
144
156
  def _fetch_account_usage_payload(
145
- usage_window_resolver: ModuleType, credentials_path: Path
157
+ usage_window_resolver: ModuleType,
158
+ credentials_path: Path,
159
+ refresh_login: Callable[[Path], object] | None,
146
160
  ) -> dict[str, object]:
147
161
  now = datetime.now().astimezone()
148
162
  try:
149
- access_token = _read_access_token(usage_window_resolver, credentials_path, now)
163
+ access_token = _read_access_token(usage_window_resolver, credentials_path, now, refresh_login)
150
164
  if access_token is None:
151
165
  raise WeeklyUtilizationProbeError(
152
166
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
@@ -166,10 +180,21 @@ def _fetch_account_usage_payload(
166
180
  ) from probe_error
167
181
 
168
182
 
169
- def probe_account_meters(credentials_path: Path) -> AccountUsageMeters:
170
- """Read short and weekly usage meters for one account."""
183
+ def probe_account_meters(
184
+ credentials_path: Path, refresh_login: Callable[[Path], object] | None = None
185
+ ) -> AccountUsageMeters:
186
+ """Read short and weekly usage meters for one account.
187
+
188
+ Args:
189
+ credentials_path: The account's CLI credential file.
190
+ refresh_login: Called with the account home when the stored token is
191
+ unusable, before one more token read.
192
+
193
+ Returns:
194
+ The account's meters.
195
+ """
171
196
  usage_window_resolver = _load_resolve_usage_window_module()
172
- usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path)
197
+ usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path, refresh_login)
173
198
  usage_windows = usage_window_resolver.extract_usage_windows(usage_payload)
174
199
  return AccountUsageMeters(
175
200
  session_utilization=usage_windows.session_utilization,
@@ -62,6 +62,13 @@ BROKER_STATE_TEMP_SUFFIX = ".json"
62
62
  BROKER_STATE_LOCK_SUFFIX = ".lock"
63
63
  ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
64
64
  CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
65
+ CLAUDE_BINARY_NAME = "claude"
66
+ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
67
+ (".local", "bin", "claude.exe"),
68
+ (".local", "bin", "claude"),
69
+ )
70
+ ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = ("-p", "ok", "--model", "haiku")
71
+ LOGIN_REFRESH_TIMEOUT_SECONDS = 120.0
65
72
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
66
73
  "CLAUDE_CODE_SESSION_ID",
67
74
  "CLAUDE_CODE_REMOTE",
@@ -97,12 +97,22 @@ def test_parse_utc_time_rejects_values_without_a_timezone(timestamp: object) ->
97
97
  assert broker_constants.parse_utc_time(timestamp) is None
98
98
 
99
99
 
100
+ def test_tightest_percent_left_should_take_the_lower_known_window() -> None:
101
+ reset = datetime(2026, 10, 3, tzinfo=timezone.utc)
102
+
103
+ assert broker_constants.Meters(30.0, reset, 70.0, reset).tightest_percent_left == 30.0
104
+ assert broker_constants.Meters(80.0, reset, 20.0, reset).tightest_percent_left == 20.0
105
+ assert broker_constants.Meters(None, None, 45.0, reset).tightest_percent_left == 45.0
106
+ assert broker_constants.Meters(None, None, None, None).tightest_percent_left is None
107
+
108
+
100
109
  def test_parse_utc_time_converts_an_offset_timestamp() -> None:
101
110
  parsed = broker_constants.parse_utc_time("2026-10-03T07:30:00-04:00")
102
111
 
103
112
  assert parsed is not None
104
113
  assert parsed.isoformat() == "2026-10-03T11:30:00+00:00"
105
114
 
115
+
106
116
  def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
107
117
  if sys.platform.startswith("win32"):
108
118
  importlib.import_module("_winapi").CreateJunction(
@@ -1234,3 +1234,28 @@ def test_should_hand_the_live_log_to_the_subprocess_runner(tmp_path: Path, monke
1234
1234
 
1235
1235
  assert outcome.returncode == 0
1236
1236
  assert captured_options["live_log"] == live_log
1237
+
1238
+
1239
+ def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1240
+ account = _account("codex-2")
1241
+ decision = account_broker.Decision("run", account, None, "most room", "normal")
1242
+
1243
+ assert account_broker.decision_payload(decision) == {
1244
+ "action": "run",
1245
+ "account": "codex-2",
1246
+ "home": str(Path("/profiles") / "codex-2"),
1247
+ "reason": "most room",
1248
+ "tier": "normal",
1249
+ "resets_at": None,
1250
+ }
1251
+
1252
+
1253
+ def test_decision_payload_should_leave_the_account_empty_and_give_the_reset_for_a_wait() -> None:
1254
+ reset = datetime(2026, 10, 3, 4, 0, tzinfo=timezone(timedelta(hours=-4)))
1255
+ decision = account_broker.Decision("wait", None, reset, "no account has room", "wait")
1256
+
1257
+ payload = account_broker.decision_payload(decision)
1258
+
1259
+ assert payload["account"] is None
1260
+ assert payload["home"] is None
1261
+ assert payload["resets_at"] == "2026-10-03T08:00:00+00:00"
@@ -114,10 +114,13 @@ def test_should_read_claude_meters_through_injected_probe(
114
114
  weekly_utilization=30.0,
115
115
  weekly_resets_at=None,
116
116
  )
117
- monkeypatch.setattr(support, "probe_account_meters", lambda _: usage)
117
+ all_refresh_helpers: list[object] = []
118
+ monkeypatch.setattr(support, "probe_account_meters", lambda _, refresh_login: all_refresh_helpers.append(refresh_login) or usage)
118
119
 
119
120
  meters = support.read_claude_meters(Account(Product.CLAUDE, "one", tmp_path))
120
121
 
122
+ assert all_refresh_helpers == [support.refresh_claude_login]
123
+
121
124
  assert meters.session_percent_left == 80.0
122
125
  assert meters.weekly_percent_left == 70.0
123
126
 
@@ -157,7 +160,7 @@ def _codex_meter_reads(
157
160
  def _claude_meter_reads(monkeypatch: pytest.MonkeyPatch, all_outcomes: list[object]) -> list[Path]:
158
161
  all_paths_read: list[Path] = []
159
162
 
160
- def probe_account_meters(credentials_path: Path) -> object:
163
+ def probe_account_meters(credentials_path: Path, refresh_login: object) -> object:
161
164
  all_paths_read.append(credentials_path)
162
165
  outcome = all_outcomes.pop(0)
163
166
  if isinstance(outcome, Exception):
@@ -516,3 +519,35 @@ def test_should_match_order_names_to_an_account_named_by_its_windows_launcher_pa
516
519
  ranked = support._with_priorities((launcher_account,), ("claude-org-jon", "claude-editor"))
517
520
 
518
521
  assert ranked[0].priority == 1
522
+
523
+
524
+ def test_should_run_the_cli_against_the_account_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
525
+ all_calls: list[tuple[list[str], str]] = []
526
+
527
+ def fake_run(command: list[str], **keyword_arguments: object) -> subprocess.CompletedProcess[bytes]:
528
+ environment = keyword_arguments["env"]
529
+ assert isinstance(environment, dict)
530
+ all_calls.append((command, environment["CLAUDE_CONFIG_DIR"]))
531
+ return subprocess.CompletedProcess(command, 0)
532
+
533
+ monkeypatch.setattr(support, "resolve_claude_path", lambda: Path("claude"))
534
+ monkeypatch.setattr(support.subprocess, "run", fake_run)
535
+
536
+ assert support.refresh_claude_login(tmp_path) is True
537
+ assert all_calls == [(["claude", "-p", "ok", "--model", "haiku"], str(tmp_path))]
538
+
539
+
540
+ def test_should_skip_the_refresh_without_a_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
541
+ monkeypatch.setattr(support, "resolve_claude_path", lambda: None)
542
+
543
+ assert support.refresh_claude_login(tmp_path) is False
544
+
545
+
546
+ def test_should_find_the_cli_in_the_local_bin_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
547
+ local_claude = tmp_path / ".local" / "bin" / "claude"
548
+ local_claude.parent.mkdir(parents=True)
549
+ local_claude.write_text("", encoding="utf-8")
550
+ monkeypatch.setattr(support.shutil, "which", lambda name: None)
551
+ monkeypatch.setattr(support.Path, "home", classmethod(lambda cls: tmp_path))
552
+
553
+ assert support.resolve_claude_path() == local_claude
@@ -184,6 +184,29 @@ class TestSyncProfile:
184
184
  assert not os.path.lexists(profile_home / "rules")
185
185
  assert report.all_unlinked == ("rules",)
186
186
 
187
+ def test_should_keep_a_link_whose_target_refuses_access(
188
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
189
+ ) -> None:
190
+ main_home = build_main_home(tmp_path)
191
+ (main_home / ".pytest_cache").mkdir()
192
+ profile_home = tmp_path / "ev"
193
+ profile_home.mkdir()
194
+ locked_link = profile_home / ".pytest_cache"
195
+ locked_link.symlink_to(main_home / ".pytest_cache", target_is_directory=True)
196
+ unlocked_stat = Path.stat
197
+
198
+ def stat_refusing_the_locked_link(
199
+ self: Path, *, follow_symlinks: bool = True
200
+ ) -> os.stat_result:
201
+ if self == locked_link and follow_symlinks:
202
+ raise PermissionError(5, "Access is denied", str(self))
203
+ return unlocked_stat(self, follow_symlinks=follow_symlinks)
204
+
205
+ monkeypatch.setattr(Path, "stat", stat_refusing_the_locked_link)
206
+ report = sync(main_home, profile_home)
207
+ assert profile.links_to(locked_link, main_home / ".pytest_cache")
208
+ assert ".pytest_cache" not in report.all_unlinked
209
+
187
210
 
188
211
  def test_should_remove_a_main_link_left_under_an_account_local_name(
189
212
  self, tmp_path: Path
@@ -389,3 +412,17 @@ def test_should_create_named_profile_and_launcher_without_changes_on_repeat(
389
412
  def test_should_reject_unsafe_profile_names(profile_name: str) -> None:
390
413
  with pytest.raises(ValueError):
391
414
  profile.default_profile_home(profile_name)
415
+
416
+
417
+ def test_sync_report_payload_should_list_each_entry_under_its_own_key() -> None:
418
+ report = profile.ProfileSyncReport(
419
+ all_linked=("CLAUDE.md", "skills"),
420
+ all_moved_aside=("settings.json",),
421
+ all_unlinked=("rules",),
422
+ )
423
+
424
+ assert profile.sync_report_payload(report) == {
425
+ "linked": ["CLAUDE.md", "skills"],
426
+ "moved_aside": ["settings.json"],
427
+ "unlinked": ["rules"],
428
+ }
@@ -65,3 +65,22 @@ def test_should_pass_the_live_log_to_the_runner_when_one_is_named(tmp_path: Path
65
65
  )
66
66
 
67
67
  assert captured_options["live_log"] == live_log
68
+
69
+
70
+ def test_should_measure_the_time_the_runner_spends_on_the_job(tmp_path: Path) -> None:
71
+ current_seconds = [50.0]
72
+
73
+ def slow_runner(product: Product, argv: list[str], **options: object) -> JobOutcome:
74
+ current_seconds[0] += 42.0
75
+ return JobOutcome(0, "{}", "", "main", (), "served", None, None)
76
+
77
+ _, elapsed_seconds = invoke_worker(
78
+ all_arguments=["claude", "-p"],
79
+ cwd=tmp_path,
80
+ prompt_text="brief",
81
+ timeout_minutes=1,
82
+ runner=slow_runner,
83
+ monotonic_clock=lambda: current_seconds[0],
84
+ )
85
+
86
+ assert elapsed_seconds == 42.0
@@ -191,3 +191,55 @@ def test_usage_module_imports_without_picker() -> None:
191
191
  )
192
192
 
193
193
  assert completed.returncode == 0, completed.stderr
194
+
195
+
196
+ def _expiring_resolver(all_tokens: list[str | None], all_fetched_tokens: list[str]) -> object:
197
+ class Windows:
198
+ session_utilization = 10.0
199
+ session_resets_at = None
200
+ weekly_utilization = 40.0
201
+ weekly_resets_at = None
202
+
203
+ class Resolver:
204
+ def read_oauth_access_token(self, credentials_path: Path, now: datetime) -> str | None:
205
+ return all_tokens.pop(0)
206
+
207
+ def default_credentials_path(self) -> Path:
208
+ return Path("session") / ".credentials.json"
209
+
210
+ def read_session_ingress_token(self) -> None:
211
+ return None
212
+
213
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
214
+ all_fetched_tokens.append(access_token)
215
+ return {}
216
+
217
+ def extract_usage_windows(self, payload: dict[str, object]) -> Windows:
218
+ return Windows()
219
+
220
+ return Resolver()
221
+
222
+
223
+ def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
224
+ account_home = tmp_path / "ev"
225
+ account_home.mkdir()
226
+ credentials_path = account_home / ".credentials.json"
227
+ credentials_path.write_text("{}", encoding="utf-8")
228
+ all_refreshed_homes: list[Path] = []
229
+ all_fetched_tokens: list[str] = []
230
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, "fresh-token"], all_fetched_tokens))
231
+
232
+ meters = usage.probe_account_meters(credentials_path, all_refreshed_homes.append)
233
+
234
+ assert all_refreshed_homes == [account_home]
235
+ assert all_fetched_tokens == ["fresh-token"]
236
+ assert meters.weekly_utilization == 40.0
237
+
238
+
239
+ def test_should_report_no_token_when_the_refresh_saves_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
240
+ credentials_path = tmp_path / ".credentials.json"
241
+ credentials_path.write_text("{}", encoding="utf-8")
242
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, None], []))
243
+
244
+ with pytest.raises(usage.WeeklyUtilizationProbeError, match="bearer token"):
245
+ usage.probe_account_meters(credentials_path, lambda home: True)