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.
Files changed (51) hide show
  1. package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
  2. package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
  3. package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
  4. package/.agents/skills/team-advisor/SKILL.md +2 -2
  5. package/_shared/advisor/reference/identity.md +1 -1
  6. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  7. package/_shared/advisor/reference/warm-up.md +3 -1
  8. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
  9. package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
  10. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
  11. package/docs/references/advisor-tool.md +3 -2
  12. package/docs/second-claude-account.md +57 -0
  13. package/hooks/blocking/test_pii_scanner.py +8 -0
  14. package/hooks/hooks.json +25 -0
  15. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  16. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
  17. package/hooks/session/skill_loaded_reminder.py +199 -0
  18. package/hooks/session/test_skill_loaded_reminder.py +206 -0
  19. package/package.json +1 -1
  20. package/rules/durable-post-artifacts.md +26 -1
  21. package/rules/long-horizon-autonomy.md +11 -0
  22. package/rules/prefer-existing-tools.md +35 -0
  23. package/rules/review-closure-is-a-check.md +1 -1
  24. package/scripts/claude_account_choice.py +291 -0
  25. package/scripts/claude_account_profile.py +371 -0
  26. package/scripts/claude_chain_usage.py +49 -5
  27. package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
  28. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
  29. package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
  30. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
  31. package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
  32. package/scripts/durable_post_lint.py +47 -1
  33. package/scripts/durable_post_lint_config/config/constants.py +1 -0
  34. package/scripts/gh_artifact_upload.py +56 -3
  35. package/scripts/private_term_scan.py +135 -0
  36. package/scripts/private_terms.py +87 -0
  37. package/scripts/repository_checks/config/constants.py +3 -0
  38. package/scripts/repository_checks/runner.py +7 -0
  39. package/scripts/repository_checks/tracked_private_terms.py +57 -0
  40. package/scripts/test_claude_account_choice.py +245 -0
  41. package/scripts/test_claude_account_profile.py +220 -0
  42. package/scripts/test_claude_chain_usage.py +40 -0
  43. package/scripts/test_durable_post_lint.py +90 -0
  44. package/scripts/tests/test_gh_artifact_upload.py +75 -0
  45. package/scripts/tests/test_match_exemptions.py +2 -2
  46. package/scripts/tests/test_private_term_scan.py +176 -0
  47. package/scripts/tests/test_private_terms.py +59 -0
  48. package/scripts/tests/test_repository_policy.py +2 -0
  49. package/scripts/tests/test_runner.py +20 -0
  50. package/scripts/tests/test_tracked_private_terms.py +71 -0
  51. 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
- 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
@@ -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."""