claude-dev-env 8.12.0 → 8.13.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 (74) hide show
  1. package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
  2. package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
  3. package/_shared/advisor/reference/astra-rung.md +7 -7
  4. package/_shared/advisor/reference/third-party-bind.md +2 -2
  5. package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
  6. package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
  7. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
  8. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
  9. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
  10. package/_shared/pr-loop/scripts/check_convergence.py +54 -49
  11. package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
  13. package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
  14. package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
  15. package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
  16. package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
  17. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
  18. package/bin/git_hooks_installer.test.mjs +1 -1
  19. package/bin/install-constants.mjs +1 -6
  20. package/bin/install.cursor-rules.test.mjs +24 -51
  21. package/bin/install.mjs +16 -70
  22. package/docs/codex-accounts.md +79 -0
  23. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
  24. package/hooks/blocking/test_pii_scanner.py +1 -1
  25. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
  26. package/hooks/git-hooks/conftest.py +16 -0
  27. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
  28. package/hooks/git-hooks/test_post_commit.py +1 -1
  29. package/hooks/git-hooks/test_pre_commit.py +1 -1
  30. package/hooks/git-hooks/test_pre_push.py +2 -2
  31. package/hooks/git-hooks/test_verification_start.py +1 -1
  32. package/hooks/git-hooks/verification_notice.py +2 -2
  33. package/hooks/git-hooks/verification_notice_context.py +21 -2
  34. package/hooks/git-hooks/verification_start.py +2 -3
  35. package/package.json +1 -1
  36. package/rules/durable-post-artifacts.md +4 -3
  37. package/scripts/claude_account_profile.py +21 -9
  38. package/scripts/codex_account_choice.py +396 -0
  39. package/scripts/codex_account_meters.py +372 -0
  40. package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
  41. package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
  42. package/scripts/private_term_scan.py +6 -2
  43. package/scripts/repository_checks/config/constants.py +1 -0
  44. package/scripts/repository_checks/tracked_private_terms.py +7 -0
  45. package/scripts/test_claude_account_profile.py +12 -0
  46. package/scripts/test_codex_account_choice.py +276 -0
  47. package/scripts/test_codex_account_meters.py +205 -0
  48. package/scripts/tests/test_automatic_advisory.py +3 -3
  49. package/scripts/tests/test_automatic_advisory_polling.py +1 -1
  50. package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
  51. package/scripts/tests/test_closed_pr_label.py +1 -1
  52. package/scripts/tests/test_private_term_scan.py +28 -0
  53. package/scripts/tests/test_review_closure.py +2 -2
  54. package/scripts/tests/test_review_closure_model.py +2 -2
  55. package/scripts/tests/test_rules.py +2 -83
  56. package/scripts/tests/test_tracked_private_terms.py +15 -0
  57. package/scripts/tests/test_verification_notice.py +29 -13
  58. package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
  59. package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
  60. package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
  61. package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
  62. package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
  63. package/scripts/sync_to_cursor/AGENTS.md +0 -1
  64. package/scripts/sync_to_cursor/CLAUDE.md +0 -1
  65. package/scripts/sync_to_cursor/__init__.py +0 -13
  66. package/scripts/sync_to_cursor/canonical_docs.py +0 -66
  67. package/scripts/sync_to_cursor/config/__init__.py +0 -8
  68. package/scripts/sync_to_cursor/engine.py +0 -219
  69. package/scripts/sync_to_cursor/hashing.py +0 -7
  70. package/scripts/sync_to_cursor/paths.py +0 -18
  71. package/scripts/sync_to_cursor/rules.py +0 -494
  72. package/scripts/sync_to_cursor.py +0 -22
  73. package/scripts/tests/test_engine.py +0 -101
  74. package/scripts/tests/test_sync_to_cursor.py +0 -315
@@ -1,82 +0,0 @@
1
- """Constants for the Codex weekly-usage probe CLI.
2
-
3
- Groups: gate threshold, probe report JSON keys, app-server JSON-RPC surface,
4
- rate-limit snapshot field keys, weekly-window sizing, parse patterns for
5
- text status output, source labels, and exit codes.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- WEEKLY_USAGE_GATE_THRESHOLD_PERCENT = 10
11
-
12
- USAGE_REPORT_KEY_PERCENT_LEFT = "percent_left"
13
- USAGE_REPORT_KEY_WINDOW_RESET = "window_reset"
14
- USAGE_REPORT_KEY_SOURCE = "source"
15
-
16
- CODEX_BINARY_NAME = "codex"
17
- ALL_APP_SERVER_COMMAND_PARTS = ("app-server", "--listen", "stdio://")
18
- APP_SERVER_TIMEOUT_SECONDS = 30
19
- READER_THREAD_JOIN_TIMEOUT_SECONDS = 2
20
- PROCESS_TREE_WAIT_TIMEOUT_SECONDS = 5
21
- WINDOWS_OS_NAME = "nt"
22
- WINDOWS_COMMAND_SHELL = "cmd"
23
- WINDOWS_COMMAND_SHELL_RUN_FLAG = "/c"
24
- ALL_WINDOWS_SCRIPT_SUFFIXES = (".cmd", ".bat")
25
-
26
- JSONRPC_VERSION = "2.0"
27
- JSONRPC_KEY_ID = "id"
28
- JSONRPC_KEY_METHOD = "method"
29
- JSONRPC_KEY_PARAMS = "params"
30
- JSONRPC_KEY_REPLY_BODY = "result"
31
- JSONRPC_KEY_ERROR = "error"
32
- JSONRPC_KEY_VERSION = "jsonrpc"
33
-
34
- INITIALIZE_REQUEST_ID = 1
35
- RATE_LIMITS_REQUEST_ID = 2
36
- METHOD_INITIALIZE = "initialize"
37
- METHOD_INITIALIZED = "initialized"
38
- METHOD_RATE_LIMITS_READ = "account/rateLimits/read"
39
-
40
- CLIENT_INFO_KEY = "clientInfo"
41
- CLIENT_INFO_NAME_KEY = "name"
42
- CLIENT_INFO_VERSION_KEY = "version"
43
- CLIENT_INFO_NAME = "codex-usage-probe"
44
- CLIENT_INFO_VERSION = "1.0.0"
45
- CAPABILITIES_KEY = "capabilities"
46
- EXPERIMENTAL_API_KEY = "experimentalApi"
47
-
48
- RATE_LIMITS_KEY = "rateLimits"
49
- PRIMARY_WINDOW_KEY = "primary"
50
- SECONDARY_WINDOW_KEY = "secondary"
51
- USED_PERCENT_KEY = "usedPercent"
52
- RESETS_AT_KEY = "resetsAt"
53
- WINDOW_DURATION_MINS_KEY = "windowDurationMins"
54
-
55
- WEEKLY_WINDOW_DURATION_MINUTES = 10080
56
- PERCENT_FULL = 100
57
- PERCENT_EMPTY = 0
58
-
59
- SOURCE_APP_SERVER_RATE_LIMITS = "codex app-server account/rateLimits/read"
60
- SOURCE_TEXT_STATUS = "text-status"
61
- SOURCE_NO_USAGE_SURFACE = "no-usage-surface"
62
-
63
- TEXT_WEEKLY_PERCENT_LEFT_PATTERN = (
64
- r"(?i)weekly(?:\s+(?:limit|usage|cap))?"
65
- r"[^\n%]{0,40}?"
66
- r"(?P<percent>\d{1,3}(?:\.\d+)?)\s*%\s*(?:left|remaining)"
67
- )
68
- TEXT_WEEKLY_USED_PERCENT_PATTERN = (
69
- r"(?i)weekly(?:\s+(?:limit|usage|cap))?"
70
- r"[^\n%]{0,40}?"
71
- r"(?P<percent>\d{1,3}(?:\.\d+)?)\s*%\s*(?:used|consumed)"
72
- )
73
- TEXT_WINDOW_RESET_PATTERN = (
74
- r"(?i)(?:weekly\s+)?(?:window\s+)?resets?(?:\s+at)?"
75
- r"[:\s]+(?P<reset>[^\s)\].,;]+)"
76
- )
77
-
78
- EXIT_CODE_SUCCESS = 0
79
- EXIT_CODE_CRASH = 1
80
-
81
- UTF8_ENCODING = "utf-8"
82
- NEWLINE = "\n"
@@ -1,573 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Report remaining weekly Codex usage as machine-readable JSON.
3
-
4
- Discovery (codex-cli 0.144.3): no dedicated ``codex usage`` subcommand.
5
- ``codex login status`` prints auth only. Weekly meters come from the
6
- app-server JSON-RPC method ``account/rateLimits/read`` via
7
- ``codex app-server --listen stdio://``. The weekly window is the rate-limit
8
- window whose ``windowDurationMins`` is 10080 (seven days), preferring
9
- ``secondary`` when both primary and secondary carry that duration.
10
-
11
- stdout is one JSON object ``{percent_left, window_reset, source}``.
12
- ``percent_left`` is 0-100, or null when the binary offers no usage data.
13
- Exit 0 when the probe completed against a reachable CLI (including null
14
- data). Exit non-zero when the Codex CLI is missing or broken, or when the
15
- probe itself crashes — consumers must not treat that as a usage-threshold
16
- skip.
17
-
18
- Gate rule for consumers: null or unknown ``percent_left`` on exit 0 counts
19
- as at-or-below ``WEEKLY_USAGE_GATE_THRESHOLD_PERCENT`` — the gate may skip
20
- and never blocks on missing meters. A non-zero probe exit is CLI failure
21
- (classify ``codex_down`` or fail closed), not a threshold skip. Require
22
- review only when exit 0 and ``is_codex_review_required(percent_left)``.
23
-
24
- ::
25
-
26
- python codex_usage_probe.py
27
- {"percent_left": 5, "window_reset": "2026-07-19T23:49:08+00:00",
28
- "source": "codex app-server account/rateLimits/read"}
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- import json
34
- import math
35
- import os
36
- import re
37
- import shutil
38
- import subprocess
39
- import sys
40
- import threading
41
- from collections.abc import Mapping, Sequence
42
- from datetime import datetime, timezone
43
- from pathlib import Path
44
- from typing import IO, Protocol
45
-
46
- _here = Path(__file__).resolve()
47
- _skills_home_directory = _here.parents[3]
48
- _package_or_user_home = (
49
- _skills_home_directory.parent
50
- if _skills_home_directory.name == ".agents"
51
- else _skills_home_directory
52
- )
53
- _shared_under_package = _package_or_user_home / "_shared" / "process-tree" / "scripts"
54
- _shared_process_tree_scripts_directory = (
55
- _shared_under_package
56
- if _shared_under_package.is_dir()
57
- else _package_or_user_home / ".claude" / "_shared" / "process-tree" / "scripts"
58
- )
59
- if str(_shared_process_tree_scripts_directory) not in sys.path:
60
- sys.path.insert(0, str(_shared_process_tree_scripts_directory))
61
-
62
- from process_tree_kill import ( # noqa: E402
63
- should_start_new_session,
64
- terminate_process_tree,
65
- )
66
-
67
- from codex_review_scripts_constants.codex_usage_probe_constants import ( # noqa: E402
68
- ALL_APP_SERVER_COMMAND_PARTS,
69
- ALL_WINDOWS_SCRIPT_SUFFIXES,
70
- APP_SERVER_TIMEOUT_SECONDS,
71
- CAPABILITIES_KEY,
72
- CLIENT_INFO_KEY,
73
- CLIENT_INFO_NAME,
74
- CLIENT_INFO_NAME_KEY,
75
- CLIENT_INFO_VERSION,
76
- CLIENT_INFO_VERSION_KEY,
77
- CODEX_BINARY_NAME,
78
- EXIT_CODE_CRASH,
79
- EXIT_CODE_SUCCESS,
80
- EXPERIMENTAL_API_KEY,
81
- INITIALIZE_REQUEST_ID,
82
- JSONRPC_KEY_ERROR,
83
- JSONRPC_KEY_ID,
84
- JSONRPC_KEY_METHOD,
85
- JSONRPC_KEY_PARAMS,
86
- JSONRPC_KEY_REPLY_BODY,
87
- JSONRPC_KEY_VERSION,
88
- JSONRPC_VERSION,
89
- METHOD_INITIALIZE,
90
- METHOD_INITIALIZED,
91
- METHOD_RATE_LIMITS_READ,
92
- NEWLINE,
93
- PERCENT_EMPTY,
94
- PERCENT_FULL,
95
- PRIMARY_WINDOW_KEY,
96
- PROCESS_TREE_WAIT_TIMEOUT_SECONDS,
97
- RATE_LIMITS_KEY,
98
- RATE_LIMITS_REQUEST_ID,
99
- READER_THREAD_JOIN_TIMEOUT_SECONDS,
100
- RESETS_AT_KEY,
101
- SECONDARY_WINDOW_KEY,
102
- SOURCE_APP_SERVER_RATE_LIMITS,
103
- SOURCE_NO_USAGE_SURFACE,
104
- SOURCE_TEXT_STATUS,
105
- TEXT_WEEKLY_PERCENT_LEFT_PATTERN,
106
- TEXT_WEEKLY_USED_PERCENT_PATTERN,
107
- TEXT_WINDOW_RESET_PATTERN,
108
- USAGE_REPORT_KEY_PERCENT_LEFT,
109
- USAGE_REPORT_KEY_SOURCE,
110
- USAGE_REPORT_KEY_WINDOW_RESET,
111
- USED_PERCENT_KEY,
112
- UTF8_ENCODING,
113
- WEEKLY_USAGE_GATE_THRESHOLD_PERCENT,
114
- WEEKLY_WINDOW_DURATION_MINUTES,
115
- WINDOW_DURATION_MINS_KEY,
116
- WINDOWS_COMMAND_SHELL,
117
- WINDOWS_COMMAND_SHELL_RUN_FLAG,
118
- WINDOWS_OS_NAME,
119
- )
120
-
121
- from subprocess_window_access import hidden_window_creation_flags
122
-
123
-
124
- class AppServerExchange(Protocol):
125
- """Callable that sends JSON-RPC request objects and returns stdout lines."""
126
-
127
- def __call__(
128
- self, all_request_messages: list[dict[str, object]]
129
- ) -> list[str]:
130
- """Exchange request messages for server stdout lines.
131
-
132
- Args:
133
- all_request_messages: JSON-RPC request/notification objects to send.
134
-
135
- Returns:
136
- Raw stdout lines from the app-server process.
137
- """
138
-
139
-
140
- UsageReport = dict[str, float | str | None]
141
-
142
-
143
- def is_codex_review_required(percent_left: float | None) -> bool:
144
- """Return whether the weekly gate should require a Codex review pass.
145
-
146
- ::
147
-
148
- None -> False # missing data never blocks
149
- 10.0 -> False # at threshold: skip
150
- 10.1 -> True # above threshold: require
151
-
152
- Args:
153
- percent_left: Weekly percent remaining, or None when unknown.
154
-
155
- Returns:
156
- True only when percent_left is known and strictly greater than
157
- WEEKLY_USAGE_GATE_THRESHOLD_PERCENT.
158
- """
159
- if percent_left is None:
160
- return False
161
- return percent_left > WEEKLY_USAGE_GATE_THRESHOLD_PERCENT
162
-
163
-
164
- def _null_usage_report() -> UsageReport:
165
- return {
166
- USAGE_REPORT_KEY_PERCENT_LEFT: None,
167
- USAGE_REPORT_KEY_WINDOW_RESET: None,
168
- USAGE_REPORT_KEY_SOURCE: SOURCE_NO_USAGE_SURFACE,
169
- }
170
-
171
-
172
- def _clamp_percent(raw_percent: float) -> float:
173
- return max(float(PERCENT_EMPTY), min(float(PERCENT_FULL), raw_percent))
174
-
175
-
176
- def _format_resets_at(raw_resets_at: object) -> str | None:
177
- if isinstance(raw_resets_at, bool):
178
- return None
179
- if isinstance(raw_resets_at, (int, float)):
180
- if not math.isfinite(raw_resets_at):
181
- return None
182
- try:
183
- return datetime.fromtimestamp(
184
- float(raw_resets_at), tz=timezone.utc
185
- ).isoformat()
186
- except (OverflowError, OSError, ValueError):
187
- return None
188
- if isinstance(raw_resets_at, str):
189
- stripped = raw_resets_at.strip()
190
- return stripped or None
191
- return None
192
-
193
-
194
- def _window_used_percent(all_window_fields: Mapping[str, object]) -> float | None:
195
- raw_used_percent = all_window_fields.get(USED_PERCENT_KEY)
196
- if isinstance(raw_used_percent, bool):
197
- return None
198
- if isinstance(raw_used_percent, (int, float)):
199
- if not math.isfinite(raw_used_percent):
200
- return None
201
- return float(raw_used_percent)
202
- return None
203
-
204
-
205
- def _window_duration_minutes(
206
- all_window_fields: Mapping[str, object],
207
- ) -> int | None:
208
- raw_duration = all_window_fields.get(WINDOW_DURATION_MINS_KEY)
209
- if isinstance(raw_duration, bool):
210
- return None
211
- if isinstance(raw_duration, (int, float)):
212
- if not math.isfinite(raw_duration):
213
- return None
214
- return int(raw_duration)
215
- return None
216
-
217
-
218
- def _select_weekly_window(
219
- all_rate_limit_fields: Mapping[str, object],
220
- ) -> Mapping[str, object] | None:
221
- primary_payload = all_rate_limit_fields.get(PRIMARY_WINDOW_KEY)
222
- secondary_payload = all_rate_limit_fields.get(SECONDARY_WINDOW_KEY)
223
- all_candidate_windows: list[Mapping[str, object]] = []
224
- if isinstance(secondary_payload, Mapping):
225
- all_candidate_windows.append(secondary_payload)
226
- if isinstance(primary_payload, Mapping):
227
- all_candidate_windows.append(primary_payload)
228
- if not all_candidate_windows:
229
- return None
230
- for each_window in all_candidate_windows:
231
- if _window_duration_minutes(each_window) == WEEKLY_WINDOW_DURATION_MINUTES:
232
- return each_window
233
- return None
234
-
235
-
236
- def _usage_report_from_window(
237
- all_weekly_window_fields: Mapping[str, object],
238
- source: str,
239
- ) -> UsageReport:
240
- used_percent = _window_used_percent(all_weekly_window_fields)
241
- if used_percent is None:
242
- return _null_usage_report()
243
- percent_left = _clamp_percent(float(PERCENT_FULL) - used_percent)
244
- return {
245
- USAGE_REPORT_KEY_PERCENT_LEFT: percent_left,
246
- USAGE_REPORT_KEY_WINDOW_RESET: _format_resets_at(
247
- all_weekly_window_fields.get(RESETS_AT_KEY)
248
- ),
249
- USAGE_REPORT_KEY_SOURCE: source,
250
- }
251
-
252
-
253
- def _parse_rate_limits_snapshot(
254
- all_rate_limit_fields: Mapping[str, object],
255
- ) -> UsageReport:
256
- weekly_window = _select_weekly_window(all_rate_limit_fields)
257
- if weekly_window is None:
258
- return _null_usage_report()
259
- return _usage_report_from_window(weekly_window, SOURCE_APP_SERVER_RATE_LIMITS)
260
-
261
-
262
- def parse_rate_limits_message(message_text: str) -> UsageReport:
263
- """Parse one JSON-RPC reply line that may carry rateLimits.
264
-
265
- Args:
266
- message_text: A single stdout line from ``codex app-server``.
267
-
268
- Returns:
269
- The usage report, or a null report when the line has no usable meters.
270
- """
271
- try:
272
- message_payload = json.loads(message_text)
273
- except json.JSONDecodeError:
274
- return _null_usage_report()
275
- if not isinstance(message_payload, dict):
276
- return _null_usage_report()
277
- if message_payload.get(JSONRPC_KEY_ERROR) is not None:
278
- return _null_usage_report()
279
- reply_body = message_payload.get(JSONRPC_KEY_REPLY_BODY)
280
- if not isinstance(reply_body, dict):
281
- return _null_usage_report()
282
- all_rate_limit_fields = reply_body.get(RATE_LIMITS_KEY)
283
- if not isinstance(all_rate_limit_fields, dict):
284
- return _null_usage_report()
285
- return _parse_rate_limits_snapshot(all_rate_limit_fields)
286
-
287
-
288
- def _text_status_report(
289
- percent_left: float,
290
- status_text: str,
291
- search_from_index: int,
292
- ) -> UsageReport:
293
- weekly_scoped_text = status_text[search_from_index:]
294
- reset_match = re.search(TEXT_WINDOW_RESET_PATTERN, weekly_scoped_text)
295
- window_reset = (
296
- reset_match.group("reset").strip() if reset_match is not None else None
297
- )
298
- return {
299
- USAGE_REPORT_KEY_PERCENT_LEFT: percent_left,
300
- USAGE_REPORT_KEY_WINDOW_RESET: window_reset,
301
- USAGE_REPORT_KEY_SOURCE: SOURCE_TEXT_STATUS,
302
- }
303
-
304
-
305
- def parse_text_usage_status(status_text: str) -> UsageReport:
306
- """Parse human status text for a weekly percent remaining.
307
-
308
- Args:
309
- status_text: Captured CLI or TUI status text.
310
-
311
- Returns:
312
- The usage report when a weekly percent pattern matches, else null.
313
- """
314
- left_match = re.search(TEXT_WEEKLY_PERCENT_LEFT_PATTERN, status_text)
315
- if left_match is not None:
316
- percent_left = _clamp_percent(float(left_match.group("percent")))
317
- return _text_status_report(
318
- percent_left,
319
- status_text,
320
- search_from_index=left_match.start(),
321
- )
322
- used_match = re.search(TEXT_WEEKLY_USED_PERCENT_PATTERN, status_text)
323
- if used_match is not None:
324
- percent_left = _clamp_percent(
325
- float(PERCENT_FULL) - float(used_match.group("percent"))
326
- )
327
- return _text_status_report(
328
- percent_left,
329
- status_text,
330
- search_from_index=used_match.start(),
331
- )
332
- return _null_usage_report()
333
-
334
-
335
- def _build_app_server_request_messages() -> list[dict[str, object]]:
336
- initialize_request: dict[str, object] = {
337
- JSONRPC_KEY_VERSION: JSONRPC_VERSION,
338
- JSONRPC_KEY_ID: INITIALIZE_REQUEST_ID,
339
- JSONRPC_KEY_METHOD: METHOD_INITIALIZE,
340
- JSONRPC_KEY_PARAMS: {
341
- CLIENT_INFO_KEY: {
342
- CLIENT_INFO_NAME_KEY: CLIENT_INFO_NAME,
343
- CLIENT_INFO_VERSION_KEY: CLIENT_INFO_VERSION,
344
- },
345
- CAPABILITIES_KEY: {EXPERIMENTAL_API_KEY: True},
346
- },
347
- }
348
- initialized_notification: dict[str, object] = {
349
- JSONRPC_KEY_VERSION: JSONRPC_VERSION,
350
- JSONRPC_KEY_METHOD: METHOD_INITIALIZED,
351
- JSONRPC_KEY_PARAMS: {},
352
- }
353
- rate_limits_request: dict[str, object] = {
354
- JSONRPC_KEY_VERSION: JSONRPC_VERSION,
355
- JSONRPC_KEY_ID: RATE_LIMITS_REQUEST_ID,
356
- JSONRPC_KEY_METHOD: METHOD_RATE_LIMITS_READ,
357
- JSONRPC_KEY_PARAMS: {},
358
- }
359
- return [initialize_request, initialized_notification, rate_limits_request]
360
-
361
-
362
- def _resolve_codex_command() -> list[str]:
363
- codex_path = shutil.which(CODEX_BINARY_NAME)
364
- if codex_path is None:
365
- raise FileNotFoundError(f"{CODEX_BINARY_NAME} binary not found on PATH")
366
- lower_path = codex_path.lower()
367
- if os.name == WINDOWS_OS_NAME and lower_path.endswith(
368
- ALL_WINDOWS_SCRIPT_SUFFIXES
369
- ):
370
- return [
371
- WINDOWS_COMMAND_SHELL,
372
- WINDOWS_COMMAND_SHELL_RUN_FLAG,
373
- codex_path,
374
- *ALL_APP_SERVER_COMMAND_PARTS,
375
- ]
376
- return [codex_path, *ALL_APP_SERVER_COMMAND_PARTS]
377
-
378
-
379
- def _is_rate_limits_reply(message_text: str) -> bool:
380
- """Return whether a stdout line is the reply to our rate-limits request."""
381
- try:
382
- message_payload = json.loads(message_text)
383
- except json.JSONDecodeError:
384
- return False
385
- if not isinstance(message_payload, dict):
386
- return False
387
- return message_payload.get(JSONRPC_KEY_ID) == RATE_LIMITS_REQUEST_ID
388
-
389
-
390
- def _collect_server_lines(
391
- server_stdout: IO[str],
392
- all_server_lines: list[str],
393
- is_exchange_over: threading.Event,
394
- ) -> None:
395
- """Append server stdout lines until the rate-limits reply lands or stdout ends."""
396
- try:
397
- for each_line in server_stdout:
398
- stripped_line = each_line.strip()
399
- if not stripped_line:
400
- continue
401
- all_server_lines.append(stripped_line)
402
- if _is_rate_limits_reply(stripped_line):
403
- break
404
- except (OSError, ValueError):
405
- pass
406
- is_exchange_over.set()
407
-
408
-
409
- def _terminate_process_tree(server_process: subprocess.Popen[str]) -> None:
410
- """Stop the app-server process and any children it launched, then reap it.
411
-
412
- On Windows the probe may wrap a ``.cmd``/``.bat`` shim as ``cmd /c``, so
413
- ``Popen.pid`` is ``cmd.exe``. Killing only that process leaves the real
414
- Codex app-server grandchild alive and holding stdout. The shared tree kill
415
- ends the whole tree on either platform; the timed wait that follows reaps
416
- the direct child so the enclosing ``with`` block exits without a long wait.
417
- """
418
- terminate_process_tree(server_process)
419
- try:
420
- server_process.wait(timeout=PROCESS_TREE_WAIT_TIMEOUT_SECONDS)
421
- except subprocess.TimeoutExpired:
422
- return
423
-
424
-
425
- def _teardown_app_server_exchange(
426
- server_process: subprocess.Popen[str],
427
- reader_thread: threading.Thread,
428
- ) -> None:
429
- """Terminate the process tree, then join the stdout reader before return."""
430
- _terminate_process_tree(server_process)
431
- reader_thread.join(timeout=READER_THREAD_JOIN_TIMEOUT_SECONDS)
432
-
433
-
434
- def _exchange_app_server_messages_via_subprocess(
435
- all_request_messages: list[dict[str, object]],
436
- ) -> list[str]:
437
- """Run ``codex app-server`` and collect its replies to the probe's requests.
438
-
439
- ::
440
-
441
- write requests -> stdin stays OPEN -> server answers -> read reply -> close
442
-
443
- The server treats end-of-input on stdin as a shutdown signal, so it exits
444
- without answering when stdin closes right after the requests are written.
445
- Stdin is held open until the rate-limits reply arrives or the timeout runs
446
- out, then the process tree is torn down and the stdout reader is joined.
447
-
448
- Args:
449
- all_request_messages: JSON-RPC request/notification objects to send.
450
-
451
- Returns:
452
- The non-empty stdout lines the server emitted before the exchange ended.
453
- """
454
- all_command_parts = _resolve_codex_command()
455
- stdin_text = NEWLINE.join(
456
- json.dumps(each_message) for each_message in all_request_messages
457
- ) + NEWLINE
458
- all_server_lines: list[str] = []
459
- is_exchange_over = threading.Event()
460
- with subprocess.Popen(
461
- all_command_parts,
462
- stdin=subprocess.PIPE,
463
- stdout=subprocess.PIPE,
464
- stderr=subprocess.DEVNULL,
465
- text=True,
466
- encoding=UTF8_ENCODING,
467
- shell=False,
468
- start_new_session=should_start_new_session(),
469
- creationflags=hidden_window_creation_flags(),
470
- ) as server_process:
471
- server_stdin = server_process.stdin
472
- server_stdout = server_process.stdout
473
- if server_stdin is None or server_stdout is None:
474
- _terminate_process_tree(server_process)
475
- raise OSError("codex app-server offered no stdio pipes")
476
- reader_thread = threading.Thread(
477
- target=_collect_server_lines,
478
- args=(server_stdout, all_server_lines, is_exchange_over),
479
- daemon=True,
480
- )
481
- reader_thread.start()
482
- try:
483
- server_stdin.write(stdin_text)
484
- server_stdin.flush()
485
- is_exchange_over.wait(timeout=APP_SERVER_TIMEOUT_SECONDS)
486
- finally:
487
- _teardown_app_server_exchange(
488
- server_process=server_process,
489
- reader_thread=reader_thread,
490
- )
491
- return list(all_server_lines)
492
-
493
-
494
- def _usage_report_from_server_lines(all_server_lines: Sequence[str]) -> UsageReport:
495
- for each_line in all_server_lines:
496
- usage_report = parse_rate_limits_message(each_line)
497
- if usage_report[USAGE_REPORT_KEY_PERCENT_LEFT] is not None:
498
- return usage_report
499
- return _null_usage_report()
500
-
501
-
502
- def probe_weekly_usage(
503
- exchange_app_server_messages: AppServerExchange,
504
- ) -> UsageReport:
505
- """Probe weekly Codex usage through the app-server rate-limits surface.
506
-
507
- Args:
508
- exchange_app_server_messages: JSON-RPC exchange seam. Production passes
509
- the real subprocess exchange; tests inject a fake.
510
-
511
- Returns:
512
- The usage report with percent_left, window_reset, and source.
513
- """
514
- all_request_messages = _build_app_server_request_messages()
515
- all_server_lines = exchange_app_server_messages(all_request_messages)
516
- return _usage_report_from_server_lines(all_server_lines)
517
-
518
-
519
- def probe_weekly_usage_via_subprocess() -> UsageReport:
520
- """Probe weekly usage against the real ``codex app-server`` subprocess.
521
-
522
- ::
523
-
524
- probe_weekly_usage_via_subprocess()
525
- # ok: {"percent_left": 62.0, "window_reset": ..., "source": ...}
526
-
527
- The production entry point for every caller that has no exchange seam of
528
- its own to inject.
529
-
530
- Returns:
531
- The usage report with percent_left, window_reset, and source.
532
- """
533
- return probe_weekly_usage(
534
- exchange_app_server_messages=_exchange_app_server_messages_via_subprocess
535
- )
536
-
537
-
538
- def main() -> int:
539
- """Run the probe and print one JSON object on stdout.
540
-
541
- ::
542
-
543
- reachable CLI, meters present -> exit 0, percent_left number
544
- reachable CLI, no meters -> exit 0, percent_left null (usage skip ok)
545
- missing or broken CLI -> exit non-zero (not a usage skip)
546
-
547
- Returns:
548
- EXIT_CODE_SUCCESS on a completed probe against a reachable CLI
549
- (including null data), or EXIT_CODE_CRASH when the Codex CLI is
550
- missing or broken or the probe itself fails.
551
- """
552
- try:
553
- usage_report = probe_weekly_usage(
554
- exchange_app_server_messages=_exchange_app_server_messages_via_subprocess
555
- )
556
- except (
557
- FileNotFoundError,
558
- OSError,
559
- subprocess.TimeoutExpired,
560
- subprocess.SubprocessError,
561
- UnicodeDecodeError,
562
- ):
563
- print(json.dumps(_null_usage_report()))
564
- return EXIT_CODE_CRASH
565
- except (json.JSONDecodeError, ValueError, TypeError, KeyError):
566
- print(json.dumps(_null_usage_report()))
567
- return EXIT_CODE_CRASH
568
- print(json.dumps(usage_report))
569
- return EXIT_CODE_SUCCESS
570
-
571
-
572
- if __name__ == "__main__":
573
- sys.exit(main())