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.
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +15 -8
- package/hooks/hooks_constants/test_image_read_size_gate_constants.py +61 -0
- package/hooks/hooks_constants/test_post_tool_use_context.py +13 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +11 -0
- package/hooks/hooks_constants/test_stdout_flush_support.py +17 -0
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +28 -0
- package/package.json +1 -1
- package/scripts/account_broker_support.py +53 -1
- package/scripts/claude_account_profile.py +11 -1
- package/scripts/claude_chain_usage.py +31 -6
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +7 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +10 -0
- package/scripts/test_account_broker.py +25 -0
- package/scripts/test_account_broker_support.py +37 -2
- package/scripts/test_claude_account_profile.py +37 -0
- package/scripts/test_claude_account_worker_process.py +19 -0
- package/scripts/test_claude_chain_usage.py +52 -0
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
"""Behavioral tests for the artifact template pointer's path."""
|
|
2
2
|
|
|
3
|
-
from hooks_constants
|
|
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
|
@@ -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
|
|
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,
|
|
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,
|
|
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(
|
|
170
|
-
|
|
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
|
-
|
|
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)
|