claude-dev-env 8.7.0 → 8.9.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.
- package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +57 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +291 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +245 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Keep the second account's Claude profile linked to the main Claude home.
|
|
3
|
+
|
|
4
|
+
The second account runs Claude with its own sign-in and history, and with
|
|
5
|
+
everything else the main home holds: skills, rules, plugins, settings, docs::
|
|
6
|
+
|
|
7
|
+
main home profile home (second account)
|
|
8
|
+
skills/ <----- skills/ link
|
|
9
|
+
CLAUDE.md <----- CLAUDE.md link
|
|
10
|
+
.credentials.json .credentials.json its own sign-in
|
|
11
|
+
projects/ projects/ its own history
|
|
12
|
+
|
|
13
|
+
Each run converges the profile on that picture. A stale copy moves into
|
|
14
|
+
``.replaced/<time>/`` with its content kept, a link whose main entry is gone
|
|
15
|
+
is removed, a main link under an account-local name such as a credentials
|
|
16
|
+
backup is removed, and a second run changes nothing. The run also writes the
|
|
17
|
+
``claude-ev.cmd`` launcher that starts Claude on this profile.
|
|
18
|
+
|
|
19
|
+
::
|
|
20
|
+
|
|
21
|
+
python claude_account_profile.py
|
|
22
|
+
{"linked": ["CLAUDE.md"], "moved_aside": [], "unlinked": [],
|
|
23
|
+
"launcher": "C:/Users/me/.local/bin/claude-ev.cmd"}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import filecmp
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from dev_env_scripts_constants.claude_account_constants import (
|
|
39
|
+
ALL_ACCOUNT_LOCAL_NAME_PREFIXES,
|
|
40
|
+
ALL_ACCOUNT_LOCAL_NAMES,
|
|
41
|
+
ALL_WINDOWS_JUNCTION_COMMAND_PREFIX,
|
|
42
|
+
JSON_LAUNCHER_KEY,
|
|
43
|
+
JSON_LINKED_KEY,
|
|
44
|
+
JSON_MOVED_ASIDE_KEY,
|
|
45
|
+
JSON_UNLINKED_KEY,
|
|
46
|
+
ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
|
|
47
|
+
LAUNCHER_FILE_NAME,
|
|
48
|
+
LAUNCHER_REPLACED_SUFFIX,
|
|
49
|
+
LAUNCHER_TEXT_TEMPLATE,
|
|
50
|
+
MAIN_CLAUDE_HOME_DIRECTORY_NAME,
|
|
51
|
+
PROFILES_ROOT_DIRECTORY_NAME,
|
|
52
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
53
|
+
REPLACED_DIRECTORY_NAME,
|
|
54
|
+
REPLACED_STAMP_FORMAT,
|
|
55
|
+
SECOND_ACCOUNT_PROFILE_NAME,
|
|
56
|
+
TEXT_ENCODING,
|
|
57
|
+
WINDOWS_EXTENDED_PATH_PREFIX,
|
|
58
|
+
WINDOWS_OS_NAME,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ProfileSyncReport:
|
|
64
|
+
"""What one sync run changed, by top-level entry name."""
|
|
65
|
+
|
|
66
|
+
all_linked: tuple[str, ...]
|
|
67
|
+
all_moved_aside: tuple[str, ...]
|
|
68
|
+
all_unlinked: tuple[str, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_profile_home() -> Path:
|
|
72
|
+
"""Locate the second account's profile directory.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The profile under the profiles root the environment names, else under
|
|
76
|
+
the default profiles root in the user home.
|
|
77
|
+
"""
|
|
78
|
+
profiles_root = os.environ.get(PROFILES_ROOT_ENVIRONMENT_VARIABLE)
|
|
79
|
+
root = (
|
|
80
|
+
Path(profiles_root)
|
|
81
|
+
if profiles_root
|
|
82
|
+
else Path.home() / PROFILES_ROOT_DIRECTORY_NAME
|
|
83
|
+
)
|
|
84
|
+
return root / SECOND_ACCOUNT_PROFILE_NAME
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_account_local(entry_name: str) -> bool:
|
|
88
|
+
"""Tell whether a Claude home entry belongs to one account only.
|
|
89
|
+
|
|
90
|
+
::
|
|
91
|
+
|
|
92
|
+
".credentials.json", "projects", ".claude.json.backup" -> True
|
|
93
|
+
".credentials.json.bak" -> True
|
|
94
|
+
"skills", "CLAUDE.md", "settings.json" -> False
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
entry_name: A top-level entry name in a Claude home.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True when the entry holds one account's sign-in, state, or history.
|
|
101
|
+
"""
|
|
102
|
+
return entry_name in ALL_ACCOUNT_LOCAL_NAMES or entry_name.startswith(
|
|
103
|
+
ALL_ACCOUNT_LOCAL_NAME_PREFIXES
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_link(entry_path: Path) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
os.readlink(entry_path)
|
|
110
|
+
except OSError:
|
|
111
|
+
return False
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _comparable_path(path_text: str) -> str:
|
|
116
|
+
return os.path.normcase(
|
|
117
|
+
os.path.abspath(path_text.removeprefix(WINDOWS_EXTENDED_PATH_PREFIX))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _link_target_text(link_path: Path) -> str:
|
|
122
|
+
target_text = os.readlink(link_path)
|
|
123
|
+
if os.path.isabs(target_text.removeprefix(WINDOWS_EXTENDED_PATH_PREFIX)):
|
|
124
|
+
return target_text
|
|
125
|
+
return os.path.join(os.path.dirname(link_path), target_text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def links_to(link_path: Path, source_path: Path) -> bool:
|
|
129
|
+
"""Tell whether a profile entry already resolves to its main-home source.
|
|
130
|
+
|
|
131
|
+
::
|
|
132
|
+
|
|
133
|
+
profile/rules -> main/rules (link or junction) -> True
|
|
134
|
+
profile/CLAUDE.md, hard link to main/CLAUDE.md -> True
|
|
135
|
+
profile/rules -> old-shared/rules -> False
|
|
136
|
+
profile/CLAUDE.md, a separate copy -> False
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
link_path: The entry in the profile home.
|
|
140
|
+
source_path: The entry in the main home it should resolve to.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True when the entry is a link to the source or the same file on disk.
|
|
144
|
+
"""
|
|
145
|
+
if _is_link(link_path):
|
|
146
|
+
return _comparable_path(_link_target_text(link_path)) == _comparable_path(
|
|
147
|
+
str(source_path)
|
|
148
|
+
)
|
|
149
|
+
if link_path.is_file() and source_path.is_file():
|
|
150
|
+
return os.path.samefile(link_path, source_path)
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _link_directory(source_path: Path, link_path: Path) -> None:
|
|
155
|
+
if os.name == WINDOWS_OS_NAME:
|
|
156
|
+
subprocess.run(
|
|
157
|
+
[*ALL_WINDOWS_JUNCTION_COMMAND_PREFIX, str(link_path), str(source_path)],
|
|
158
|
+
check=True,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
os.symlink(source_path, link_path, target_is_directory=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _link_file(source_path: Path, link_path: Path) -> None:
|
|
166
|
+
try:
|
|
167
|
+
os.symlink(source_path, link_path)
|
|
168
|
+
except OSError:
|
|
169
|
+
os.link(source_path, link_path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _create_link(source_path: Path, link_path: Path) -> None:
|
|
173
|
+
if source_path.is_dir():
|
|
174
|
+
_link_directory(source_path, link_path)
|
|
175
|
+
return
|
|
176
|
+
_link_file(source_path, link_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _remove_link(link_path: Path) -> None:
|
|
180
|
+
try:
|
|
181
|
+
os.rmdir(link_path)
|
|
182
|
+
except NotADirectoryError:
|
|
183
|
+
os.unlink(link_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_same_content_copy(entry_path: Path, source_path: Path) -> bool:
|
|
187
|
+
return (
|
|
188
|
+
not _is_link(entry_path)
|
|
189
|
+
and entry_path.is_file()
|
|
190
|
+
and source_path.is_file()
|
|
191
|
+
and filecmp.cmp(entry_path, source_path, shallow=False)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _stamp(now: datetime) -> str:
|
|
196
|
+
return now.astimezone(timezone.utc).strftime(REPLACED_STAMP_FORMAT)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_inside(candidate_text: str, directory: Path) -> bool:
|
|
200
|
+
comparable_directory = _comparable_path(str(directory))
|
|
201
|
+
return _comparable_path(candidate_text).startswith(comparable_directory + os.sep)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _unlink_orphaned_links(main_home: Path, profile_home: Path) -> tuple[str, ...]:
|
|
205
|
+
all_unlinked: list[str] = []
|
|
206
|
+
for each_entry in sorted(profile_home.iterdir()):
|
|
207
|
+
if not _is_link(each_entry) or each_entry.exists():
|
|
208
|
+
continue
|
|
209
|
+
if not _is_inside(_link_target_text(each_entry), main_home):
|
|
210
|
+
continue
|
|
211
|
+
_remove_link(each_entry)
|
|
212
|
+
all_unlinked.append(each_entry.name)
|
|
213
|
+
return tuple(all_unlinked)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _unlink_local_names_linked_to_main(
|
|
217
|
+
main_home: Path, profile_home: Path
|
|
218
|
+
) -> tuple[str, ...]:
|
|
219
|
+
all_unlinked: list[str] = []
|
|
220
|
+
for each_entry in sorted(profile_home.iterdir()):
|
|
221
|
+
if not is_account_local(each_entry.name):
|
|
222
|
+
continue
|
|
223
|
+
if not links_to(each_entry, main_home / each_entry.name):
|
|
224
|
+
continue
|
|
225
|
+
_remove_link(each_entry)
|
|
226
|
+
all_unlinked.append(each_entry.name)
|
|
227
|
+
return tuple(all_unlinked)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _clear_entry(entry_path: Path, source_path: Path, replaced_directory: Path) -> bool:
|
|
231
|
+
if _is_same_content_copy(entry_path, source_path):
|
|
232
|
+
entry_path.unlink()
|
|
233
|
+
return False
|
|
234
|
+
replaced_directory.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
os.replace(entry_path, replaced_directory / entry_path.name)
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _link_shared_entries(
|
|
240
|
+
main_home: Path, profile_home: Path, replaced_directory: Path
|
|
241
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
242
|
+
all_linked: list[str] = []
|
|
243
|
+
all_moved_aside: list[str] = []
|
|
244
|
+
for each_source in sorted(main_home.iterdir()):
|
|
245
|
+
entry_path = profile_home / each_source.name
|
|
246
|
+
if is_account_local(each_source.name) or links_to(entry_path, each_source):
|
|
247
|
+
continue
|
|
248
|
+
if os.path.lexists(entry_path) and _clear_entry(
|
|
249
|
+
entry_path, each_source, replaced_directory
|
|
250
|
+
):
|
|
251
|
+
all_moved_aside.append(each_source.name)
|
|
252
|
+
_create_link(each_source, entry_path)
|
|
253
|
+
all_linked.append(each_source.name)
|
|
254
|
+
return tuple(all_linked), tuple(all_moved_aside)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def sync_profile(
|
|
258
|
+
*, main_home: Path, profile_home: Path, now: datetime
|
|
259
|
+
) -> ProfileSyncReport:
|
|
260
|
+
"""Link every shared main-home entry into the profile home.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
main_home: The main account's Claude home.
|
|
264
|
+
profile_home: The second account's Claude home.
|
|
265
|
+
now: The run time that names the folder stale entries move into.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
The entries the run linked, moved aside, and unlinked.
|
|
269
|
+
"""
|
|
270
|
+
profile_home.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
all_unlinked = tuple(
|
|
272
|
+
sorted(
|
|
273
|
+
_unlink_orphaned_links(main_home, profile_home)
|
|
274
|
+
+ _unlink_local_names_linked_to_main(main_home, profile_home)
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
all_linked, all_moved_aside = _link_shared_entries(
|
|
278
|
+
main_home, profile_home, profile_home / REPLACED_DIRECTORY_NAME / _stamp(now)
|
|
279
|
+
)
|
|
280
|
+
return ProfileSyncReport(
|
|
281
|
+
all_linked=all_linked,
|
|
282
|
+
all_moved_aside=all_moved_aside,
|
|
283
|
+
all_unlinked=all_unlinked,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _moved_launcher_name(now: datetime) -> str:
|
|
288
|
+
return f"{LAUNCHER_FILE_NAME}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def write_launcher(
|
|
292
|
+
*, launcher_directory: Path, profile_home: Path, now: datetime
|
|
293
|
+
) -> Path:
|
|
294
|
+
"""Write the launcher that runs Claude under the second account's profile.
|
|
295
|
+
|
|
296
|
+
::
|
|
297
|
+
|
|
298
|
+
claude-ev -p "fix the test"
|
|
299
|
+
-> CLAUDE_CONFIG_DIR=<profile home>, then claude -p "fix the test"
|
|
300
|
+
an older claude-ev.cmd -> claude-ev.cmd.replaced-<time>
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
launcher_directory: The directory on PATH that holds the launcher.
|
|
304
|
+
profile_home: The second account's Claude home.
|
|
305
|
+
now: The run time that names a moved older launcher.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The launcher path.
|
|
309
|
+
"""
|
|
310
|
+
launcher_path = launcher_directory / LAUNCHER_FILE_NAME
|
|
311
|
+
launcher_text = LAUNCHER_TEXT_TEMPLATE.format(profile_home=profile_home)
|
|
312
|
+
launcher_bytes = launcher_text.encode(TEXT_ENCODING)
|
|
313
|
+
if launcher_path.is_file() and launcher_path.read_bytes() == launcher_bytes:
|
|
314
|
+
return launcher_path
|
|
315
|
+
if launcher_path.is_file():
|
|
316
|
+
os.replace(launcher_path, launcher_path.with_name(_moved_launcher_name(now)))
|
|
317
|
+
launcher_directory.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
launcher_path.write_bytes(launcher_bytes)
|
|
319
|
+
return launcher_path
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
323
|
+
parser = argparse.ArgumentParser(
|
|
324
|
+
description="Link the main Claude home into the second account's profile."
|
|
325
|
+
)
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"--main-home", type=Path, default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
|
|
328
|
+
)
|
|
329
|
+
parser.add_argument("--profile-home", type=Path, default=default_profile_home())
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"--launcher-directory",
|
|
332
|
+
type=Path,
|
|
333
|
+
default=Path.home().joinpath(*ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS),
|
|
334
|
+
)
|
|
335
|
+
return parser
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main(all_command_arguments: list[str]) -> int:
|
|
339
|
+
"""Sync the profile, write the launcher, and print what changed as JSON.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
all_command_arguments: Command-line arguments after the program name.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Zero once the profile and launcher match the main home.
|
|
346
|
+
"""
|
|
347
|
+
arguments = _build_argument_parser().parse_args(all_command_arguments)
|
|
348
|
+
now = datetime.now(timezone.utc)
|
|
349
|
+
report = sync_profile(
|
|
350
|
+
main_home=arguments.main_home, profile_home=arguments.profile_home, now=now
|
|
351
|
+
)
|
|
352
|
+
launcher_path = write_launcher(
|
|
353
|
+
launcher_directory=arguments.launcher_directory,
|
|
354
|
+
profile_home=arguments.profile_home,
|
|
355
|
+
now=now,
|
|
356
|
+
)
|
|
357
|
+
print(
|
|
358
|
+
json.dumps(
|
|
359
|
+
{
|
|
360
|
+
JSON_LINKED_KEY: list(report.all_linked),
|
|
361
|
+
JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
|
|
362
|
+
JSON_UNLINKED_KEY: list(report.all_unlinked),
|
|
363
|
+
JSON_LAUNCHER_KEY: str(launcher_path),
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -136,8 +136,25 @@ def _load_resolve_usage_window_module() -> ModuleType:
|
|
|
136
136
|
return loaded_module
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class AccountUsageMeters:
|
|
141
|
+
"""One account's 5-hour and weekly meters, each field None when unread.
|
|
142
|
+
|
|
143
|
+
::
|
|
144
|
+
|
|
145
|
+
{"five_hour": {"utilization": 42, ...}, "seven_day": {...}}
|
|
146
|
+
-> AccountUsageMeters(42.0, <5-hour reset>, 63.0, <weekly reset>)
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
session_utilization: float | None
|
|
150
|
+
session_resets_at: datetime | None
|
|
151
|
+
weekly_utilization: float | None
|
|
152
|
+
weekly_resets_at: datetime | None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fetch_account_usage_payload(
|
|
156
|
+
usage_window_resolver: ModuleType, credentials_path: Path
|
|
157
|
+
) -> dict[str, object]:
|
|
141
158
|
now = datetime.now().astimezone()
|
|
142
159
|
try:
|
|
143
160
|
access_token = usage_window_resolver.read_oauth_access_token(
|
|
@@ -147,7 +164,7 @@ def _probe_weekly_utilization(credentials_path: Path) -> float:
|
|
|
147
164
|
raise WeeklyUtilizationProbeError(
|
|
148
165
|
NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
|
|
149
166
|
)
|
|
150
|
-
|
|
167
|
+
return usage_window_resolver._fetch_usage_payload(access_token)
|
|
151
168
|
except WeeklyUtilizationProbeError:
|
|
152
169
|
raise
|
|
153
170
|
except (
|
|
@@ -160,10 +177,37 @@ def _probe_weekly_utilization(credentials_path: Path) -> float:
|
|
|
160
177
|
raise WeeklyUtilizationProbeError(
|
|
161
178
|
USAGE_PROBE_FAILED_ERROR_TEMPLATE.format(error=probe_error)
|
|
162
179
|
) from probe_error
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def probe_account_meters(credentials_path: Path) -> AccountUsageMeters:
|
|
183
|
+
"""Read one account's usage meters through the usage-pause OAuth probe.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
credentials_path: The account's CLI credential file.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The account's 5-hour and weekly meters.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
WeeklyUtilizationProbeError: When the credential yields no usable token
|
|
193
|
+
or the probe request fails.
|
|
194
|
+
"""
|
|
195
|
+
usage_window_resolver = _load_resolve_usage_window_module()
|
|
196
|
+
usage_payload = _fetch_account_usage_payload(usage_window_resolver, credentials_path)
|
|
163
197
|
usage_windows = usage_window_resolver.extract_usage_windows(usage_payload)
|
|
164
|
-
|
|
198
|
+
return AccountUsageMeters(
|
|
199
|
+
session_utilization=usage_windows.session_utilization,
|
|
200
|
+
session_resets_at=usage_windows.session_resets_at,
|
|
201
|
+
weekly_utilization=usage_windows.weekly_utilization,
|
|
202
|
+
weekly_resets_at=usage_windows.weekly_resets_at,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _probe_weekly_utilization(credentials_path: Path) -> float:
|
|
207
|
+
weekly_utilization = probe_account_meters(credentials_path).weekly_utilization
|
|
208
|
+
if weekly_utilization is None:
|
|
165
209
|
raise WeeklyUtilizationProbeError(WEEKLY_UTILIZATION_MISSING_ERROR)
|
|
166
|
-
return float(
|
|
210
|
+
return float(weekly_utilization)
|
|
167
211
|
|
|
168
212
|
|
|
169
213
|
weekly_utilization_probe: WeeklyUtilizationProbe = _probe_weekly_utilization
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Named constants for the second Claude account: its profile, launcher, and picker.
|
|
2
|
+
|
|
3
|
+
The profile sync links the main Claude home into a second account's profile
|
|
4
|
+
directory. The launcher points Claude at that profile. The picker reads both
|
|
5
|
+
accounts' usage meters and names the account a job runs on, protecting the
|
|
6
|
+
main account so its owner never runs out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
|
|
13
|
+
MAIN_CLAUDE_HOME_DIRECTORY_NAME: str = ".claude"
|
|
14
|
+
"""Directory under the user home that holds the main account's Claude home."""
|
|
15
|
+
|
|
16
|
+
PROFILES_ROOT_DIRECTORY_NAME: str = ".claude-profiles"
|
|
17
|
+
"""Directory under the user home that holds named Claude profiles."""
|
|
18
|
+
|
|
19
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE: str = "LLM_SETTINGS_PROFILES_ROOT"
|
|
20
|
+
"""Environment variable that relocates the profiles root."""
|
|
21
|
+
|
|
22
|
+
SECOND_ACCOUNT_PROFILE_NAME: str = "ev"
|
|
23
|
+
"""Profile directory name the second account signs in under."""
|
|
24
|
+
|
|
25
|
+
ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS: tuple[str, ...] = (".local", "bin")
|
|
26
|
+
"""Path parts under the user home of the directory on PATH that holds the launcher."""
|
|
27
|
+
|
|
28
|
+
LAUNCHER_FILE_NAME: str = "claude-ev.cmd"
|
|
29
|
+
"""File name of the launcher that runs Claude under the second account."""
|
|
30
|
+
|
|
31
|
+
LAUNCHER_TEXT_TEMPLATE: str = (
|
|
32
|
+
"@echo off\r\n"
|
|
33
|
+
"setlocal\r\n"
|
|
34
|
+
'set "CLAUDE_CONFIG_DIR={profile_home}"\r\n'
|
|
35
|
+
"claude %*\r\n"
|
|
36
|
+
"exit /b %ERRORLEVEL%\r\n"
|
|
37
|
+
)
|
|
38
|
+
"""Launcher body: point Claude at the profile and pass every argument through."""
|
|
39
|
+
|
|
40
|
+
LAUNCHER_REPLACED_SUFFIX: str = ".replaced-"
|
|
41
|
+
"""Suffix, before the run time, of an older launcher the sync moved aside."""
|
|
42
|
+
|
|
43
|
+
ALL_WINDOWS_JUNCTION_COMMAND_PREFIX: tuple[str, ...] = ("cmd", "/c", "mklink", "/J")
|
|
44
|
+
"""Command that links a directory as a junction on Windows, before link and target."""
|
|
45
|
+
|
|
46
|
+
CREDENTIALS_FILE_NAME: str = ".credentials.json"
|
|
47
|
+
"""File in a Claude home that holds that account's sign-in."""
|
|
48
|
+
|
|
49
|
+
ALL_ACCOUNT_LOCAL_NAMES: frozenset[str] = frozenset(
|
|
50
|
+
{
|
|
51
|
+
".credentials.json",
|
|
52
|
+
"projects",
|
|
53
|
+
"sessions",
|
|
54
|
+
"session-env",
|
|
55
|
+
"shell-snapshots",
|
|
56
|
+
"todos",
|
|
57
|
+
"statsig",
|
|
58
|
+
"file-history",
|
|
59
|
+
"history.jsonl",
|
|
60
|
+
"ide",
|
|
61
|
+
"debug",
|
|
62
|
+
"telemetry",
|
|
63
|
+
"logs",
|
|
64
|
+
"tasks",
|
|
65
|
+
"plans",
|
|
66
|
+
"backups",
|
|
67
|
+
"cache",
|
|
68
|
+
"paste-cache",
|
|
69
|
+
"stats-cache.json",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
"""Top-level Claude home entries that belong to one account and are never linked."""
|
|
73
|
+
|
|
74
|
+
ALL_ACCOUNT_LOCAL_NAME_PREFIXES: tuple[str, ...] = (".claude.json", ".credentials.json")
|
|
75
|
+
"""Name prefixes of per-account state files, covering the global config and its backups."""
|
|
76
|
+
|
|
77
|
+
REPLACED_DIRECTORY_NAME: str = ".replaced"
|
|
78
|
+
"""Profile subdirectory that holds entries the sync moved aside, one folder per run."""
|
|
79
|
+
|
|
80
|
+
REPLACED_STAMP_FORMAT: str = "%Y%m%dT%H%M%SZ"
|
|
81
|
+
"""UTC time format naming each run's folder under the replaced directory."""
|
|
82
|
+
|
|
83
|
+
WINDOWS_EXTENDED_PATH_PREFIX: str = "\\\\?\\"
|
|
84
|
+
"""Prefix Windows puts on a junction target that the link comparison strips."""
|
|
85
|
+
|
|
86
|
+
WINDOWS_OS_NAME: str = "nt"
|
|
87
|
+
"""Value of ``os.name`` on Windows, where directories link as junctions."""
|
|
88
|
+
|
|
89
|
+
TEXT_ENCODING: str = "utf-8"
|
|
90
|
+
"""Encoding for the launcher file and the JSON reports."""
|
|
91
|
+
|
|
92
|
+
MAIN_SPEND_WINDOW: timedelta = timedelta(hours=24)
|
|
93
|
+
"""Main takes jobs only when its weekly window resets within this span."""
|
|
94
|
+
|
|
95
|
+
MAIN_WEEKLY_USED_CEILING_PERCENT: float = 90.0
|
|
96
|
+
"""Main takes jobs only while its weekly use is under this percent."""
|
|
97
|
+
|
|
98
|
+
MAIN_SESSION_USED_CEILING_PERCENT: float = 50.0
|
|
99
|
+
"""Main takes jobs only while its 5-hour use is under this percent."""
|
|
100
|
+
|
|
101
|
+
SECOND_WEEKLY_USED_CEILING_PERCENT: float = 95.0
|
|
102
|
+
"""The second account takes jobs while its weekly use is under this percent."""
|
|
103
|
+
|
|
104
|
+
SECOND_SESSION_USED_CEILING_PERCENT: float = 90.0
|
|
105
|
+
"""The second account takes jobs while its 5-hour use is under this percent."""
|
|
106
|
+
|
|
107
|
+
FULL_PERCENT: float = 100.0
|
|
108
|
+
"""Percent scale ceiling, so remaining is this minus used."""
|
|
109
|
+
|
|
110
|
+
SECONDS_PER_HOUR: int = 3600
|
|
111
|
+
"""Seconds in one hour, for reason text that names hours until a reset."""
|
|
112
|
+
|
|
113
|
+
CHOICE_MAIN: str = "main"
|
|
114
|
+
"""Picker answer: run the job on the main account."""
|
|
115
|
+
|
|
116
|
+
CHOICE_SECOND: str = "second"
|
|
117
|
+
"""Picker answer: run the job on the second account."""
|
|
118
|
+
|
|
119
|
+
CHOICE_WAIT: str = "wait"
|
|
120
|
+
"""Picker answer: neither account has room, so the job waits."""
|
|
121
|
+
|
|
122
|
+
REASON_MAIN_EXPIRING_TEMPLATE: str = (
|
|
123
|
+
"main week resets in {hours_until_reset} hours with {remaining_percent:.0f}% left"
|
|
124
|
+
)
|
|
125
|
+
"""Reason when main spends leftover usage that expires soon."""
|
|
126
|
+
|
|
127
|
+
REASON_SECOND_HAS_ROOM_TEMPLATE: str = (
|
|
128
|
+
"second account has {weekly_remaining_percent:.0f}% of its week"
|
|
129
|
+
" and {session_remaining_percent:.0f}% of its 5-hour window left"
|
|
130
|
+
)
|
|
131
|
+
"""Reason when the second account takes the job."""
|
|
132
|
+
|
|
133
|
+
REASON_SECOND_UNREAD: str = (
|
|
134
|
+
"second account meter unreadable; its own run refreshes the sign-in"
|
|
135
|
+
)
|
|
136
|
+
"""Reason when the second account's meter cannot be read and it takes the job anyway."""
|
|
137
|
+
|
|
138
|
+
REASON_WAIT_TEMPLATE: str = (
|
|
139
|
+
"second account is at {weekly_used_percent:.0f}% of its week"
|
|
140
|
+
" and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
|
|
141
|
+
)
|
|
142
|
+
"""Reason when neither account has room."""
|
|
143
|
+
|
|
144
|
+
UNKNOWN_RESET_TEXT: str = "unknown"
|
|
145
|
+
"""Reset text when the blocking meter carries no reset time."""
|
|
146
|
+
|
|
147
|
+
JSON_ACCOUNT_KEY: str = "account"
|
|
148
|
+
"""Picker JSON key naming the chosen account."""
|
|
149
|
+
|
|
150
|
+
JSON_CONFIG_DIRECTORY_KEY: str = "config_dir"
|
|
151
|
+
"""Picker JSON key naming the Claude home the chosen account runs under."""
|
|
152
|
+
|
|
153
|
+
JSON_REASON_KEY: str = "reason"
|
|
154
|
+
"""Picker JSON key carrying the plain-words reason for the choice."""
|
|
155
|
+
|
|
156
|
+
JSON_METERS_KEY: str = "meters"
|
|
157
|
+
"""Picker JSON key carrying both accounts' meters, each None when unread."""
|
|
158
|
+
|
|
159
|
+
JSON_SESSION_USED_PERCENT_KEY: str = "session_used_percent"
|
|
160
|
+
"""Meters JSON key for the percent of the 5-hour window used."""
|
|
161
|
+
|
|
162
|
+
JSON_SESSION_RESETS_AT_KEY: str = "session_resets_at"
|
|
163
|
+
"""Meters JSON key for when the 5-hour window resets."""
|
|
164
|
+
|
|
165
|
+
JSON_WEEKLY_USED_PERCENT_KEY: str = "weekly_used_percent"
|
|
166
|
+
"""Meters JSON key for the percent of the week used."""
|
|
167
|
+
|
|
168
|
+
JSON_WEEKLY_RESETS_AT_KEY: str = "weekly_resets_at"
|
|
169
|
+
"""Meters JSON key for when the week resets."""
|
|
170
|
+
|
|
171
|
+
JSON_LINKED_KEY: str = "linked"
|
|
172
|
+
"""Sync JSON key listing entries the run linked."""
|
|
173
|
+
|
|
174
|
+
JSON_MOVED_ASIDE_KEY: str = "moved_aside"
|
|
175
|
+
"""Sync JSON key listing entries the run moved into the replaced directory."""
|
|
176
|
+
|
|
177
|
+
JSON_UNLINKED_KEY: str = "unlinked"
|
|
178
|
+
"""Sync JSON key listing links the run removed because main no longer has the entry."""
|
|
179
|
+
|
|
180
|
+
JSON_LAUNCHER_KEY: str = "launcher"
|
|
181
|
+
"""Sync JSON key naming the launcher file the run wrote or confirmed."""
|
|
@@ -41,3 +41,30 @@ NOTES_FILE_SUFFIX: str = ".md"
|
|
|
41
41
|
|
|
42
42
|
UTF8_ENCODING: str = "utf-8"
|
|
43
43
|
"""Text encoding for subprocess output and temp files."""
|
|
44
|
+
|
|
45
|
+
PNG_SUFFIX: str = ".png"
|
|
46
|
+
"""Suffix of a file the upload shrinks with oxipng before it sends."""
|
|
47
|
+
|
|
48
|
+
OXIPNG_BINARY_NAME: str = "oxipng"
|
|
49
|
+
"""The oxipng executable name, looked up on PATH."""
|
|
50
|
+
|
|
51
|
+
ALL_OXIPNG_LOSSLESS_ARGUMENTS: tuple[str, ...] = (
|
|
52
|
+
"--opt",
|
|
53
|
+
"4",
|
|
54
|
+
"--strip",
|
|
55
|
+
"none",
|
|
56
|
+
"--nb",
|
|
57
|
+
"--nc",
|
|
58
|
+
"--quiet",
|
|
59
|
+
)
|
|
60
|
+
"""Oxipng options: a slow lossless search that keeps every chunk, the bit
|
|
61
|
+
depth, and the color type."""
|
|
62
|
+
|
|
63
|
+
OXIPNG_MISSING_MESSAGE: str = (
|
|
64
|
+
"oxipng is not on PATH. Install the release binary from "
|
|
65
|
+
"https://github.com/oxipng/oxipng/releases, then upload again."
|
|
66
|
+
)
|
|
67
|
+
"""Failure text when a PNG upload finds no oxipng to shrink it first."""
|
|
68
|
+
|
|
69
|
+
SIZE_REPORT_TEMPLATE: str = "{name}: {original_size} -> {optimized_size} bytes"
|
|
70
|
+
"""Line naming an upload and its size before and after oxipng."""
|