claude-dev-env 8.6.1 → 8.8.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.
Files changed (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """Name the Claude account a runner job uses, protecting the main account.
3
+
4
+ The main account is the one its owner works on, so a job borrows it only to
5
+ spend leftover usage that expires soon. Every other job goes to the second
6
+ account while that account has room, and waits when it has none::
7
+
8
+ main week resets in 5 hours, 20% left, 5-hour window 10% used -> main
9
+ main week resets in 3 days -> second
10
+ second at 99% of its week, main not spendable -> wait
11
+ main meter unread -> never main
12
+
13
+ ::
14
+
15
+ python claude_account_choice.py --main-config-dir C:/Users/me/.claude
16
+ --second-config-dir C:/Users/me/.claude-profiles/ev
17
+ {"account": "second", "config_dir": "C:/Users/me/.claude-profiles/ev",
18
+ "reason": "second account has 60% of its week and 90% of its 5-hour window left"}
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ from claude_account_profile import default_profile_home
31
+ from claude_chain_usage import (
32
+ AccountUsageMeters,
33
+ WeeklyUtilizationProbeError,
34
+ probe_account_meters,
35
+ )
36
+ from dev_env_scripts_constants.claude_account_constants import (
37
+ CHOICE_MAIN,
38
+ CHOICE_SECOND,
39
+ CHOICE_WAIT,
40
+ CREDENTIALS_FILE_NAME,
41
+ FULL_PERCENT,
42
+ JSON_ACCOUNT_KEY,
43
+ JSON_CONFIG_DIRECTORY_KEY,
44
+ JSON_REASON_KEY,
45
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
46
+ MAIN_SESSION_USED_CEILING_PERCENT,
47
+ MAIN_SPEND_WINDOW,
48
+ MAIN_WEEKLY_USED_CEILING_PERCENT,
49
+ REASON_MAIN_EXPIRING_TEMPLATE,
50
+ REASON_SECOND_HAS_ROOM_TEMPLATE,
51
+ REASON_SECOND_UNREAD,
52
+ REASON_WAIT_TEMPLATE,
53
+ SECOND_SESSION_USED_CEILING_PERCENT,
54
+ SECOND_WEEKLY_USED_CEILING_PERCENT,
55
+ SECONDS_PER_HOUR,
56
+ UNKNOWN_RESET_TEXT,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AccountDecision:
62
+ """The account a job runs on, and the plain-words reason for it."""
63
+
64
+ account: str
65
+ reason: str
66
+
67
+
68
+ def _main_spendable_reason(
69
+ main_meters: AccountUsageMeters | None, now: datetime
70
+ ) -> str | None:
71
+ if main_meters is None:
72
+ return None
73
+ session_used = main_meters.session_utilization
74
+ weekly_used = main_meters.weekly_utilization
75
+ weekly_resets_at = main_meters.weekly_resets_at
76
+ if session_used is None or weekly_used is None or weekly_resets_at is None:
77
+ return None
78
+ time_until_reset = weekly_resets_at - now
79
+ is_expiring_soon = time_until_reset <= MAIN_SPEND_WINDOW
80
+ has_weekly_room = weekly_used < MAIN_WEEKLY_USED_CEILING_PERCENT
81
+ has_session_room = session_used < MAIN_SESSION_USED_CEILING_PERCENT
82
+ if not (is_expiring_soon and has_weekly_room and has_session_room):
83
+ return None
84
+ return REASON_MAIN_EXPIRING_TEMPLATE.format(
85
+ hours_until_reset=int(time_until_reset.total_seconds() // SECONDS_PER_HOUR),
86
+ remaining_percent=FULL_PERCENT - weekly_used,
87
+ )
88
+
89
+
90
+ def _wait_decision(
91
+ second_meters: AccountUsageMeters, *, is_week_blocked: bool
92
+ ) -> AccountDecision:
93
+ blocking_reset = (
94
+ second_meters.weekly_resets_at
95
+ if is_week_blocked
96
+ else second_meters.session_resets_at
97
+ )
98
+ return AccountDecision(
99
+ account=CHOICE_WAIT,
100
+ reason=REASON_WAIT_TEMPLATE.format(
101
+ weekly_used_percent=second_meters.weekly_utilization,
102
+ session_used_percent=second_meters.session_utilization,
103
+ next_reset=(
104
+ blocking_reset.isoformat() if blocking_reset else UNKNOWN_RESET_TEXT
105
+ ),
106
+ ),
107
+ )
108
+
109
+
110
+ def _second_account_decision(
111
+ second_meters: AccountUsageMeters | None,
112
+ ) -> AccountDecision:
113
+ if second_meters is None:
114
+ return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
115
+ session_used = second_meters.session_utilization
116
+ weekly_used = second_meters.weekly_utilization
117
+ if session_used is None or weekly_used is None:
118
+ return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
119
+ is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
120
+ is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
121
+ if is_week_blocked or is_session_blocked:
122
+ return _wait_decision(second_meters, is_week_blocked=is_week_blocked)
123
+ return AccountDecision(
124
+ account=CHOICE_SECOND,
125
+ reason=REASON_SECOND_HAS_ROOM_TEMPLATE.format(
126
+ weekly_remaining_percent=FULL_PERCENT - weekly_used,
127
+ session_remaining_percent=FULL_PERCENT - session_used,
128
+ ),
129
+ )
130
+
131
+
132
+ def choose_account(
133
+ *,
134
+ main_meters: AccountUsageMeters | None,
135
+ second_meters: AccountUsageMeters | None,
136
+ now: datetime,
137
+ ) -> AccountDecision:
138
+ """Pick the account a job runs on from both accounts' meters.
139
+
140
+ ::
141
+
142
+ main: week resets in 5h, 80% used, 5-hour 10% used -> main
143
+ main: week resets in 30h -> second, if it has room
144
+ main unread, second at 99% of its week -> wait
145
+ main unread, second unread -> second
146
+
147
+ Main runs a job only to spend leftover usage that expires soon.
148
+
149
+ Args:
150
+ main_meters: The main account's meters, or None when unread.
151
+ second_meters: The second account's meters, or None when unread.
152
+ now: The time the reset windows are measured from.
153
+
154
+ Returns:
155
+ The chosen account and the reason.
156
+ """
157
+ main_reason = _main_spendable_reason(main_meters, now)
158
+ if main_reason is not None:
159
+ return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
160
+ return _second_account_decision(second_meters)
161
+
162
+
163
+ def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
164
+ """Read one account's meters, or None when its credential or probe fails.
165
+
166
+ Args:
167
+ credentials_path: The account's CLI credential file.
168
+
169
+ Returns:
170
+ The meters, or None when they cannot be read.
171
+ """
172
+ try:
173
+ return probe_account_meters(credentials_path)
174
+ except WeeklyUtilizationProbeError:
175
+ return None
176
+
177
+
178
+ def _build_argument_parser() -> argparse.ArgumentParser:
179
+ parser = argparse.ArgumentParser(
180
+ description="Name the Claude account a runner job uses."
181
+ )
182
+ parser.add_argument(
183
+ "--main-config-dir",
184
+ type=Path,
185
+ default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME,
186
+ )
187
+ parser.add_argument(
188
+ "--second-config-dir",
189
+ type=Path,
190
+ default=default_profile_home(),
191
+ )
192
+ return parser
193
+
194
+
195
+ def decision_payload(
196
+ decision: AccountDecision, *, config_directory: Path | None
197
+ ) -> dict[str, str | None]:
198
+ """Shape a decision as the JSON object the runner job reads.
199
+
200
+ ::
201
+
202
+ AccountDecision("second", "...") with the profile directory
203
+ -> {"account": "second", "config_dir": "<profile>", "reason": "..."}
204
+
205
+ Args:
206
+ decision: The chosen account and reason.
207
+ config_directory: The Claude home the account runs under, or None on wait.
208
+
209
+ Returns:
210
+ The account, its Claude home, and the reason.
211
+ """
212
+ return {
213
+ JSON_ACCOUNT_KEY: decision.account,
214
+ JSON_CONFIG_DIRECTORY_KEY: str(config_directory) if config_directory else None,
215
+ JSON_REASON_KEY: decision.reason,
216
+ }
217
+
218
+
219
+ def main(all_command_arguments: list[str]) -> int:
220
+ """Print the chosen account as JSON.
221
+
222
+ Args:
223
+ all_command_arguments: Command-line arguments after the program name.
224
+
225
+ Returns:
226
+ Zero once the choice is printed.
227
+ """
228
+ arguments = _build_argument_parser().parse_args(all_command_arguments)
229
+ config_directory_by_account = {
230
+ CHOICE_MAIN: arguments.main_config_dir,
231
+ CHOICE_SECOND: arguments.second_config_dir,
232
+ }
233
+ decision = choose_account(
234
+ main_meters=read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME),
235
+ second_meters=read_account_meters(
236
+ arguments.second_config_dir / CREDENTIALS_FILE_NAME
237
+ ),
238
+ now=datetime.now().astimezone(),
239
+ )
240
+ config_directory = config_directory_by_account.get(decision.account)
241
+ print(json.dumps(decision_payload(decision, config_directory=config_directory)))
242
+ return 0
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main(sys.argv[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
- def _probe_weekly_utilization(credentials_path: Path) -> float:
140
- usage_window_resolver = _load_resolve_usage_window_module()
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
- usage_payload = usage_window_resolver._fetch_usage_payload(access_token)
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
- if usage_windows.weekly_utilization is None:
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(usage_windows.weekly_utilization)
210
+ return float(weekly_utilization)
167
211
 
168
212
 
169
213
  weekly_utilization_probe: WeeklyUtilizationProbe = _probe_weekly_utilization