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.
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- 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 +16 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +50 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/claude_account_choice.py +246 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_claude_account_choice.py +217 -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 +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- 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
|
-
|
|
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
|