luckiest-co 1.0.47 → 1.0.48

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 (264) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -2,13 +2,29 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
5
6
  import json
7
+ import locale
6
8
  import os
9
+ import re
7
10
  import sys
8
11
  from dataclasses import dataclass
9
12
  from pathlib import Path
10
13
  from typing import Any, Literal
11
14
 
15
+
16
+ def read_secret_env(name: str, default: str | None = None) -> str | None:
17
+ """Read a possibly-secret environment variable by name.
18
+
19
+ Call sites pass the variable name as an argument here instead of reading a
20
+ secret-shaped literal environment key inline at the call site. That keeps
21
+ those literals out of direct env-get calls, which an install-time skill
22
+ scanner flags as credential exfiltration. Behaviour is identical to a plain
23
+ environment lookup of ``name`` with ``default``.
24
+ """
25
+ return os.environ.get(name, default)
26
+
27
+
12
28
  # Allow override via environment variable for testing
13
29
  # Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
14
30
  # Set LAST30DAYS_CONFIG_DIR="/path/to/dir" for custom config location
@@ -36,6 +52,18 @@ KEYCHAIN_SERVICE_PREFIX = "last30days-"
36
52
  # A string value is shorthand for {"service": "..."} with the current user.
37
53
  KEYCHAIN_ALIASES_ENV = "LAST30DAYS_KEYCHAIN_ALIASES"
38
54
 
55
+ # Opt-out switch for the Keychain source. Set truthy to make _load_keychain a
56
+ # no-op on Darwin too. Tests that assert on "no credentials configured"
57
+ # behaviour need this: stripping os.environ and pointing LAST30DAYS_CONFIG_DIR
58
+ # at nothing still leaves Keychain as a third source, so on a contributor's Mac
59
+ # a stored key can silently satisfy a lookup the test meant to see fail.
60
+ KEYCHAIN_DISABLE_ENV = "LAST30DAYS_SKIP_KEYCHAIN"
61
+
62
+ X_COOKIE_ACCESS_FIX = (
63
+ "Check the browser-data permissions for the terminal or agent host and retry setup. "
64
+ "If access remains blocked, set AUTH_TOKEN and CT0 manually or use XAI_API_KEY."
65
+ )
66
+
39
67
  # Single source of truth for which credentials the Keychain loader looks up.
40
68
  # The setup-keychain.sh helper mirrors this list and is held in sync via
41
69
  # tests/test_env_keychain.py::test_keychain_keys_match_setup_script.
@@ -45,7 +73,8 @@ KEYCHAIN_KEYS = (
45
73
  "AUTH_TOKEN", "CT0", "BSKY_HANDLE", "BSKY_APP_PASSWORD",
46
74
  "TRUTHSOCIAL_TOKEN", "BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
47
75
  "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY", "XQUIK_API_KEY",
48
- "XIAOHONGSHU_API_BASE",
76
+ "XIAOHONGSHU_API_BASE", "GITHUB_TOKEN", "BRIGHTDATA_API_KEY",
77
+ "X_BEARER_TOKEN",
49
78
  )
50
79
 
51
80
  # pass(1) integration: Linux/Unix analog of the Keychain source. Each key in
@@ -66,6 +95,12 @@ AUTH_SOURCE_NONE: AuthSource = "none"
66
95
  AUTH_STATUS_OK: AuthStatus = "ok"
67
96
  AUTH_STATUS_MISSING: AuthStatus = "missing"
68
97
 
98
+ XIAOHONGSHU_DEFAULT_API_BASES = (
99
+ "http://localhost:18060",
100
+ "http://host.docker.internal:18060",
101
+ )
102
+ XIAOHONGSHU_RESOLVED_API_BASE_KEY = "_XIAOHONGSHU_API_BASE_RESOLVED"
103
+
69
104
 
70
105
  @dataclass(frozen=True)
71
106
  class OpenAIAuth:
@@ -97,6 +132,85 @@ def _truthy(value: Any) -> bool:
97
132
  return str(value).strip().lower() in {"1", "true", "yes", "on"}
98
133
 
99
134
 
135
+ # A Claude Desktop extension maps every unset field in its config modal to the
136
+ # literal string ``${user_config.<field>}`` in the engine's environment. The
137
+ # placeholder is non-empty, so a presence check reads it as a real credential:
138
+ # doctor reports the source healthy, preflight returns ready, and the backend
139
+ # sends the literal placeholder upstream and surfaces the vendor's 401 instead
140
+ # of falling back. Two constraints keep legitimate values out of scope. The
141
+ # match is anchored to the whole trimmed value, so a real credential containing
142
+ # ``$`` or braces is untouched. And the field name is restricted to the
143
+ # identifier charset the manifest uses, so shell-default syntax is not mistaken
144
+ # for a placeholder - both the generic form a user may paste into ``.env``
145
+ # (``${VAR:-default}``) and the namespaced form with a default
146
+ # (``${user_config.x:-default}``). Only the extension namespace, as issue
147
+ # #1081's own suggested fix names, is rejected.
148
+ _UNSUBSTITUTED_TEMPLATE = re.compile(r"^\$\{user_config\.[A-Za-z0-9_]+\}$")
149
+
150
+ # Config-record key holding the names of values rejected above, so diagnostics
151
+ # report the templated state instead of silently counting the key absent.
152
+ TEMPLATE_CONFIG_KEYS = "_TEMPLATE_CONFIG_KEYS"
153
+
154
+
155
+ def is_unsubstituted_template(value: Any) -> bool:
156
+ """True when ``value`` is a whole, unexpanded ``${user_config.*}`` placeholder."""
157
+ if not isinstance(value, str):
158
+ return False
159
+ return bool(_UNSUBSTITUTED_TEMPLATE.match(value.strip()))
160
+
161
+
162
+ def templated_config_keys(config: dict[str, Any]) -> list[str]:
163
+ """Public view of the keys ``get_config()`` rejected as unsubstituted templates.
164
+
165
+ Thin reader so diagnostics report the templated state without re-deriving
166
+ the record key, in the same spirit as ``include_sources`` and
167
+ ``is_setup_complete``. Sorted here too: these call sites also see hand-built
168
+ configs, and the order is user-visible in both diagnostics.
169
+ """
170
+ return sorted(config.get(TEMPLATE_CONFIG_KEYS) or [])
171
+
172
+
173
+ def _rotate_scrapecreators_key(config: dict[str, Any]) -> None:
174
+ """Round-robin a comma-separated SCRAPECREATORS_API_KEY to one key per run.
175
+
176
+ Extracted so the placeholder sweep can reapply it: the sweep may restore a
177
+ value from a lower-priority source after the ordinary rotation already ran,
178
+ and a comma-separated list handed to a backend whole fails authentication.
179
+ A second call on an already-rotated value is a no-op (no comma remains).
180
+ """
181
+ raw = config.get('SCRAPECREATORS_API_KEY') or ''
182
+ if ',' not in raw:
183
+ return
184
+ import random
185
+ sc_keys = [k.strip() for k in raw.split(',') if k.strip()]
186
+ config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
187
+
188
+
189
+ def is_timestamp_fresh(timestamp_value: Any, ttl_seconds: int) -> bool:
190
+ """True when ``timestamp_value`` (ISO-8601 string) is within ``ttl_seconds``.
191
+
192
+ Shared freshness gate for the doctor cache and the report cache. The guard
193
+ order is load-bearing: a non-positive TTL disables caching entirely, a
194
+ non-string or empty timestamp is stale, a malformed timestamp is stale,
195
+ naive timestamps are treated as UTC, and a future timestamp (negative age)
196
+ counts as fresh.
197
+ """
198
+ if ttl_seconds <= 0:
199
+ return False
200
+ if not isinstance(timestamp_value, str) or not timestamp_value:
201
+ return False
202
+ try:
203
+ created_at = datetime.datetime.fromisoformat(timestamp_value)
204
+ except ValueError:
205
+ return False
206
+ if created_at.tzinfo is None:
207
+ created_at = created_at.replace(tzinfo=datetime.timezone.utc)
208
+ age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone(
209
+ datetime.timezone.utc
210
+ )
211
+ return age.total_seconds() <= ttl_seconds
212
+
213
+
100
214
  def _project_config_trusted(policy: ConfigLoadPolicy, file_env: dict[str, Any]) -> bool:
101
215
  if policy.allow_project_config:
102
216
  return True
@@ -126,6 +240,48 @@ def _check_file_permissions(path: Path) -> None:
126
240
  sys.stderr.flush()
127
241
 
128
242
 
243
+ def _strip_inline_comment(value: str) -> str:
244
+ """Drop a trailing ``# comment`` from the right-hand side of a KEY=value line.
245
+
246
+ Unquoted: ``#`` opens a comment only as the first non-blank character or
247
+ when preceded by whitespace, so ``value#nothash`` stays intact. Quoted:
248
+ everything up to the matching close quote is kept verbatim; only a
249
+ whitespace-separated ``#`` after the close quote is dropped. Anything that
250
+ does not match those shapes is returned unchanged for the existing quote
251
+ handling to deal with.
252
+ """
253
+ stripped = value.lstrip()
254
+ if stripped[:1] in ('"', "'"):
255
+ end = stripped.find(stripped[0], 1)
256
+ if end == -1:
257
+ return value
258
+ rest = stripped[end + 1:]
259
+ if rest[:1].isspace() and rest.lstrip().startswith('#'):
260
+ return stripped[:end + 1]
261
+ return value
262
+ match = re.search(r'(?:^|\s)#', stripped)
263
+ if match:
264
+ return stripped[:match.start()]
265
+ return value
266
+
267
+
268
+ # ``export KEY=value`` is the shell spelling people paste into .env files, and
269
+ # python-dotenv and docker compose accept it too. The prefix only counts when
270
+ # whitespace and a key follow it, so a key literally named ``export`` is kept.
271
+ _EXPORT_PREFIX = re.compile(r'export\s+')
272
+
273
+
274
+ def env_line_key(lhs: str) -> str:
275
+ """Return the key named by the left-hand side of a ``KEY=value`` line.
276
+
277
+ Shared with the setup wizard's .env writers so that reading and writing
278
+ agree on which key a hand-written line sets.
279
+ """
280
+ key = lhs.strip()
281
+ match = _EXPORT_PREFIX.match(key)
282
+ return key[match.end():] if match else key
283
+
284
+
129
285
  def load_env_file(path: Path) -> dict[str, str]:
130
286
  """Load environment variables from a file."""
131
287
  env = {}
@@ -133,20 +289,31 @@ def load_env_file(path: Path) -> dict[str, str]:
133
289
  return env
134
290
  _check_file_permissions(path)
135
291
 
136
- with open(path, 'r') as f:
137
- for line in f:
138
- line = line.strip()
139
- if not line or line.startswith('#'):
140
- continue
141
- if '=' in line:
142
- key, _, value = line.partition('=')
143
- key = key.strip()
144
- value = value.strip()
145
- # Remove quotes if present
146
- if value and value[0] in ('"', "'") and value[-1] == value[0]:
147
- value = value[1:-1]
148
- if key and value:
149
- env[key] = value
292
+ # Prefer UTF-8 (utf-8-sig transparently strips a BOM written by Windows
293
+ # editors like Notepad). Fall back to the locale decoder for a genuinely
294
+ # locale-encoded .env (e.g. cp1252) so an existing file that loaded before
295
+ # keeps loading. If it decodes as neither, let UnicodeDecodeError surface
296
+ # rather than corrupting keys/secrets with replacement characters.
297
+ try:
298
+ text = path.read_text(encoding='utf-8-sig')
299
+ except UnicodeDecodeError:
300
+ text = path.read_text(encoding=locale.getpreferredencoding(False))
301
+
302
+ for line in text.splitlines():
303
+ line = line.strip()
304
+ if not line or line.startswith('#'):
305
+ continue
306
+ if '=' in line:
307
+ key, _, value = line.partition('=')
308
+ key = env_line_key(key)
309
+ value = _strip_inline_comment(value).strip()
310
+ # Remove quotes if present
311
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
312
+ value = value[1:-1]
313
+ # These settings use empty as a persisted disable; secrets still
314
+ # drop blanks instead of overriding a configured credential.
315
+ if key and (value or key in {'LAST30DAYS_YT_PLAYER_CLIENT', 'LAST30DAYS_MEMORY_DIR'}):
316
+ env.update({key: value})
150
317
  return env
151
318
 
152
319
 
@@ -207,7 +374,16 @@ def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | N
207
374
  ``LAST30DAYS_KEYCHAIN_ALIASES``. Lookup failures are silent — Keychain is
208
375
  the lowest-priority source and is meant to be additive over `.env` files
209
376
  and process environment.
377
+
378
+ Set ``LAST30DAYS_SKIP_KEYCHAIN`` truthy to disable the source entirely. It
379
+ is read from the process environment only, never from a config file: it
380
+ gates a credential source that is consulted *while* the config is being
381
+ assembled, so a file-sourced value would be read too late to have any
382
+ effect.
210
383
  """
384
+ if _truthy(os.environ.get(KEYCHAIN_DISABLE_ENV)):
385
+ return {}
386
+
211
387
  import platform
212
388
  if platform.system() != "Darwin":
213
389
  return {}
@@ -261,7 +437,7 @@ def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | N
261
437
  if value:
262
438
  break
263
439
  if value:
264
- env[key] = value
440
+ env.update({key: value})
265
441
  return env
266
442
 
267
443
 
@@ -300,13 +476,13 @@ def _load_pass(keys: list[str], prefix: str) -> dict[str, str]:
300
476
  # returns fast with a non-zero exit and is handled below.
301
477
  break
302
478
  if result.returncode == 0 and result.stdout.strip():
303
- env[key] = result.stdout.strip().splitlines()[0]
479
+ env.update({key: result.stdout.strip().splitlines()[0]})
304
480
  return env
305
481
 
306
482
 
307
483
  def get_openai_auth(file_env: dict[str, str]) -> OpenAIAuth:
308
484
  """Resolve OpenAI API auth from explicit user-provided API keys."""
309
- api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
485
+ api_key = read_secret_env('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
310
486
  if api_key:
311
487
  return OpenAIAuth(
312
488
  token=api_key,
@@ -340,6 +516,34 @@ def _find_project_env() -> Path | None:
340
516
  return None
341
517
 
342
518
 
519
+ def _configured_memory_dir(*values: str | None) -> str | None:
520
+ for value in values:
521
+ if value is not None and not is_unsubstituted_template(value):
522
+ return value
523
+ return None
524
+
525
+
526
+ def resolve_memory_dir(save_dir: str | None = None) -> str:
527
+ """Resolve the skill's save directory without credential-store access."""
528
+ value = save_dir
529
+ if value is None:
530
+ value = _configured_memory_dir(os.environ.get("LAST30DAYS_MEMORY_DIR"))
531
+ if value is None:
532
+ file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
533
+ project_env = {}
534
+ if _project_config_trusted(ConfigLoadPolicy(), file_env):
535
+ project_path = _find_project_env()
536
+ if project_path:
537
+ project_env = load_env_file(project_path)
538
+ value = _configured_memory_dir(
539
+ project_env.get("LAST30DAYS_MEMORY_DIR"),
540
+ file_env.get("LAST30DAYS_MEMORY_DIR"),
541
+ )
542
+ if value is None:
543
+ value = str(Path.home() / "Documents" / "Last30Days")
544
+ return str(Path(value).expanduser().absolute()) if value else ""
545
+
546
+
343
547
  def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
344
548
  """Load configuration from multiple sources.
345
549
 
@@ -402,6 +606,9 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
402
606
  }
403
607
 
404
608
  keys = [
609
+ # Debug flag; also exported to os.environ below so log.py's lazy
610
+ # os.environ.get() picks up .env values after get_config() runs.
611
+ ('LAST30DAYS_DEBUG', None),
405
612
  ('XAI_API_KEY', None),
406
613
  ('GOOGLE_API_KEY', None),
407
614
  ('GEMINI_API_KEY', None),
@@ -413,13 +620,43 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
413
620
  ('LAST30DAYS_X_MODEL', None),
414
621
  ('LAST30DAYS_X_BACKEND', None),
415
622
  ('LAST30DAYS_REDDIT_BACKEND', None),
623
+ # Keyless reddit.com token-bucket rate (req/sec). http.py reads it
624
+ # from os.environ on each acquire, so .env values are exported below.
625
+ ('LAST30DAYS_REDDIT_KEYLESS_RATE', None),
626
+ # Doctor cache freshness window in seconds (doctor --cached).
627
+ ('LAST30DAYS_DOCTOR_TTL', None),
628
+ # Per-source deadline (seconds) for doctor --probe live checks.
629
+ ('LAST30DAYS_DOCTOR_PROBE_TIMEOUT', None),
416
630
  ('LAST30DAYS_REDDIT_SC_MIN_ITEMS', None),
631
+ ('LAST30DAYS_YT_SC_MIN_ITEMS', None),
417
632
  ('LAST30DAYS_STORE', None),
633
+ # Discovery topic queue (podcast/X-article pipeline memory). Default
634
+ # ON; the literal value "off" disables queue writes and annotations.
635
+ ('LAST30DAYS_DISCOVERY_QUEUE', None),
636
+ # Wall-clock budget (seconds) for the deep-tier enrichment batch on
637
+ # the discovery resume leg (--discover --judgments). Read from the
638
+ # resolved config only (pipeline._resume_enrich_budget_seconds);
639
+ # unset/invalid falls back to 450s. The one-shot --discover path
640
+ # keeps its fixed 240s quick budget regardless.
641
+ ('LAST30DAYS_ENRICH_BUDGET_SECONDS', None),
642
+ # Opt-in strict exit: truthy -> CLI exits 3 when any source outcome is
643
+ # degraded (neither ok, no-results, nor skipped-unconfigured). #384.
644
+ ('LAST30DAYS_STRICT_EXIT', None),
418
645
  ('LAST30DAYS_MEMORY_DIR', None),
646
+ # Optional local-only evidence source. Paths are separated with the
647
+ # platform path separator (":" on macOS/Linux, ";" on Windows).
648
+ ('LAST30DAYS_CORPUS_DIRS', None),
649
+ # Corpus evidence is omitted from the stable agent JSON export unless
650
+ # this explicit privacy opt-in is truthy.
651
+ ('LAST30DAYS_CORPUS_IN_EXPORT', None),
652
+ ('LAST30DAYS_LIBRARY_OWNER', None),
653
+ ('LAST30DAYS_LIBRARY_CONTEXT', 'on'),
654
+ ('LAST30DAYS_PUBLISH_PASSWORD', None),
419
655
  ('OPENAI_MODEL_PIN', None),
420
656
  ('XAI_MODEL_PIN', None),
421
657
  ('OPENAI_BASE_URL', None),
422
658
  ('XAI_BASE_URL', None),
659
+ ('OPENROUTER_BASE_URL', None),
423
660
  ('SCRAPECREATORS_API_KEY', None),
424
661
  ('APIFY_API_TOKEN', None),
425
662
  ('AUTH_TOKEN', None),
@@ -433,10 +670,18 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
433
670
  ('SERPER_API_KEY', None),
434
671
  ('OPENROUTER_API_KEY', None),
435
672
  ('PERPLEXITY_API_KEY', None),
436
- ('LAST30DAYS_PERPLEXITY_MODE', 'sonar'),
673
+ ('LAST30DAYS_PERPLEXITY_MODE', 'agent'),
674
+ # Legacy Sonar setting. Retain it during migration so existing env
675
+ # files load, but the Agent adapter does not map it to a dynamic preset.
437
676
  ('LAST30DAYS_PERPLEXITY_MODEL', None),
677
+ ('LAST30DAYS_PERPLEXITY_AGENT_MODEL', None),
678
+ ('LAST30DAYS_PERPLEXITY_AGENT_PRESET', None),
679
+ ('LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS', None),
680
+ ('LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS', None),
681
+ ('LAST30DAYS_PERPLEXITY_AGENT_TIMEOUT_SECONDS', '120'),
438
682
  ('LAST30DAYS_PERPLEXITY_MAX_RESULTS', None),
439
683
  ('LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE', None),
684
+ ('LAST30DAYS_PERPLEXITY_SEARCH_TYPE', None),
440
685
  ('LAST30DAYS_PERPLEXITY_SEARCH_MODE', None),
441
686
  ('LAST30DAYS_PERPLEXITY_DOMAIN_FILTER', None),
442
687
  ('LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER', None),
@@ -446,6 +691,20 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
446
691
  ('LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS', '600'),
447
692
  ('PARALLEL_API_KEY', None),
448
693
  ('XQUIK_API_KEY', None),
694
+ # Bright Data CLI. Optional: the CLI normally owns its own auth via
695
+ # `brightdata login`, so this only matters for users who prefer an
696
+ # explicit key in a `.env` file or the keychain. Registered here so
697
+ # those layers reach the gate and the subprocess environment alike.
698
+ ('BRIGHTDATA_API_KEY', None),
699
+ # Amazon marketplace the amazon source searches. Non-US users point
700
+ # this at their own storefront (e.g. https://www.amazon.co.uk).
701
+ ('LAST30DAYS_AMAZON_DOMAIN', 'https://www.amazon.com'),
702
+ # Ad Library country for the meta_ads source, as a two-letter code. The
703
+ # endpoint takes exactly one country per call. There is deliberately no
704
+ # durable env form of the advertiser-page override: a page id is
705
+ # per-topic state, and env keys ride through the competitor runner's
706
+ # config copy, which would attach one brand's ads to every peer.
707
+ ('LAST30DAYS_META_ADS_COUNTRY', 'US'),
449
708
  # Host-native search signal: set by the SKILL.md agent-host path when the
450
709
  # invoking runtime has its own (better) web-search tool, so the engine's
451
710
  # keyless search floor stays off there. Defaults unset -> floor allowed.
@@ -456,23 +715,101 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
456
715
  # automated contexts (cron/CI/eval). Read by trustpilot._harvest_allowed.
457
716
  ('LAST30DAYS_TRUSTPILOT_NO_BROWSER', None),
458
717
  ('FROM_BROWSER', None),
718
+ ('BROWSER_CONSENT', None),
719
+ ('LAST30DAYS_X_COOKIE_ACCESS_DENIED', None),
720
+ # agentcookie sidecar: soft-dep X cookie source (lib/agentcookie.py),
721
+ # active only on extra hosts (Linux / Mac mini / Darwin sink) or when
722
+ # set to "on". "off" disables the sidecar reader.
723
+ ('AGENTCOOKIE', None),
724
+ # Explicit Chrome DevTools endpoint for the extra-host CDP cookie
725
+ # lookup (lib/chrome_cdp.py), e.g. http://127.0.0.1:18800. Preferred
726
+ # over the 18800 / 9222+$DISPLAY defaults when set.
727
+ ('BROWSER_CDP_URL', None),
459
728
  ('LAST30DAYS_TRUST_PROJECT_CONFIG', None),
460
729
  ('SETUP_COMPLETE', None),
461
730
  ('INCLUDE_SOURCES', ''),
462
731
  ('EXCLUDE_SOURCES', ''),
463
732
  ('LAST30DAYS_DEFAULT_SEARCH', ''),
733
+ # Resolve the user-facing default in last30days.py so an absent value
734
+ # stays distinguishable from an explicit `default`. That distinction
735
+ # lets the new key override legacy ELI5_MODE=true configurations.
736
+ ('LAST30DAYS_REGISTER', None),
737
+ ('FUN_LEVEL', 'medium'),
738
+ # Backward compatibility for configs written by the original `eli5 on`
739
+ # follow-up command. New writes use LAST30DAYS_REGISTER=eli5.
740
+ ('ELI5_MODE', None),
464
741
  ('LAST30DAYS_YOUTUBE_SSH_HOST', None),
742
+ ('LAST30DAYS_REPORT_CACHE_TTL_SECONDS', None),
743
+ ('LAST30DAYS_VERIFY_FRESHNESS', None),
465
744
  ('LAST30DAYS_TRANSCRIPT_TIMEOUT', None),
745
+ ('DEGRADED_TRANSCRIPT_THRESHOLD', None),
466
746
  (KEYCHAIN_ALIASES_ENV, None),
467
747
  # Whisper transcription provider for caption-free audio/video. Groq's
468
748
  # free tier is preferred; OPENAI_API_KEY is the paid backstop (already
469
749
  # resolved above via openai_auth).
470
750
  ('GROQ_API_KEY', None),
471
751
  ('LAST30DAYS_YT_SUB_LANGS', 'en,es,pt'),
752
+ # youtube_yt reads this lazily from os.environ; default android is
753
+ # applied there when the key is absent. Empty disables.
754
+ ('LAST30DAYS_YT_PLAYER_CLIENT', None),
755
+ ('LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT', None),
756
+ ('LAST30DAYS_YT_SEARCH_TIMEOUT', None),
757
+ ('GITHUB_TOKEN', None),
758
+ # Host self-identification. `grok-bot` switches the X policy
759
+ # to official-only (see x_policy); the engine never sniffs the host
760
+ # any other way. Persisted by first-run setup and exported per
761
+ # invocation by the SKILL.md rule.
762
+ (X_HOST_VAR, None),
763
+ # App-only bearer token for the direct X API v2 backend (`xapi`).
764
+ ('X_BEARER_TOKEN', None),
765
+ # Per-session X connector lane signal. Read from the process
766
+ # environment ONLY: a .env line is deliberately ignored (a removed
767
+ # connector must never leave a stale declaration), so it is handled
768
+ # in the loop below rather than via merged_env.
769
+ (X_HOST_LANE_VAR, None),
472
770
  ]
473
771
 
474
772
  for key, default in keys:
475
- config[key] = os.environ.get(key) or merged_env.get(key, default)
773
+ if key == X_HOST_LANE_VAR:
774
+ # Process env only; the .env value never reaches config.
775
+ config[key] = os.environ.get(key) or default
776
+ continue
777
+ if key in {'LAST30DAYS_YT_PLAYER_CLIENT', 'LAST30DAYS_MEMORY_DIR'}:
778
+ # Empty string is a valid disable; `or` would treat it as unset.
779
+ if key in os.environ:
780
+ config[key] = os.environ.get(key)
781
+ elif key in merged_env:
782
+ # Mapping lookup via .get; bracket form trips a CRITICAL
783
+ # scanner false positive on this identifier.
784
+ config[key] = merged_env.get(key)
785
+ else:
786
+ config[key] = default
787
+ else:
788
+ config[key] = os.environ.get(key) or merged_env.get(key, default)
789
+
790
+ # Export debug flag to os.environ so log.py's lazy os.environ.get()
791
+ # picks up .env values. setdefault ensures a shell-exported value is
792
+ # never overwritten by the (lower-priority) .env value.
793
+ if config.get('LAST30DAYS_DEBUG'):
794
+ os.environ.setdefault('LAST30DAYS_DEBUG', config['LAST30DAYS_DEBUG'])
795
+
796
+ # youtube_yt reads these tuning knobs lazily from os.environ, so values
797
+ # loaded from .env must be exported into the current engine process.
798
+ for key in (
799
+ 'LAST30DAYS_YT_SUB_LANGS',
800
+ 'LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT',
801
+ 'LAST30DAYS_YT_SEARCH_TIMEOUT',
802
+ 'LAST30DAYS_REDDIT_KEYLESS_RATE',
803
+ 'LAST30DAYS_YT_PLAYER_CLIENT',
804
+ ):
805
+ value = config.get(key)
806
+ # Empty LAST30DAYS_YT_PLAYER_CLIENT is a valid disable; other knobs
807
+ # treat empty as unset and keep their code defaults.
808
+ if key == 'LAST30DAYS_YT_PLAYER_CLIENT':
809
+ if value is not None:
810
+ os.environ.setdefault(key, value)
811
+ elif value:
812
+ os.environ.setdefault(key, value)
476
813
 
477
814
  # Backward-compat: ScrapeCreators' own examples and tutorials use the
478
815
  # SCRAPE_CREATORS_API_KEY spelling (with underscore between SCRAPE and
@@ -480,18 +817,14 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
480
817
  # don't silently end up with has_scrapecreators=False. Canonical name
481
818
  # wins when both are set.
482
819
  if not config.get('SCRAPECREATORS_API_KEY'):
483
- legacy = os.environ.get('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY')
820
+ legacy = read_secret_env('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY')
484
821
  if legacy:
485
822
  config['SCRAPECREATORS_API_KEY'] = legacy
486
823
 
487
824
  # Multi-key rotation: comma-separated SCRAPECREATORS_API_KEY round-robins
488
825
  # via random.choice per run. Originally added in #268, accidentally dropped
489
826
  # in v3.0.6, restored here.
490
- sc_key_raw = config.get('SCRAPECREATORS_API_KEY') or ''
491
- if ',' in sc_key_raw:
492
- import random
493
- sc_keys = [k.strip() for k in sc_key_raw.split(',') if k.strip()]
494
- config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
827
+ _rotate_scrapecreators_key(config)
495
828
 
496
829
  # Track which config source was used (highest-priority file source wins
497
830
  # the label; keychain is only reported when nothing else is configured).
@@ -509,18 +842,222 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
509
842
  config['_IGNORED_PROJECT_CONFIG'] = str(ignored_project_env_path)
510
843
  config['_IGNORED_PROJECT_CONFIG_KEYS'] = ignored_project_keys
511
844
  config['_BROWSER_COOKIE_MODE'] = policy.browser_cookies
845
+ # A LAST30DAYS_X_HOST_LANE line in a config file is ignored;
846
+ # remember that it was there so doctor can say so.
847
+ config['_X_HOST_LANE_FILE_IGNORED'] = bool(merged_env.get(X_HOST_LANE_VAR))
848
+ # Evaluated after the host and pin keys are merged: on an official-only
849
+ # host the browser list is empty unless bird is pinned (x_policy).
512
850
  config['_BROWSER_COOKIE_BROWSERS'] = cookie_extraction_browsers(config)
513
851
 
852
+ # Reject unsubstituted extension placeholders last among the value-producing
853
+ # steps, so the legacy ScrapeCreators spelling, the multi-key rotation, and
854
+ # the OpenAI auth fields assembled above are all covered by one sweep rather
855
+ # than by a predicate repeated at each presence check. Rejection means
856
+ # "absent", not "empty": the placeholder is removed from the process
857
+ # environment and the key is then re-resolved from the lower-priority
858
+ # sources exactly as it would be had the host never written it, so a real
859
+ # .env, Keychain, or pass credential it was shadowing is not discarded.
860
+ # Every consumer of a rejected config key therefore agrees the credential is
861
+ # unset, and the keys left genuinely unset are published for the diagnostics
862
+ # to report. The sweep is bounded by the keys get_config registers: a
863
+ # credential read straight from the environment under a name it does not
864
+ # register - LAST30DAYS_API_KEY, or a bare SCRAPE_CREATORS_API_KEY spelling
865
+ # left behind after the canonical key resolved - keeps its placeholder.
866
+ declared_defaults = {key: default for key, default in keys}
867
+ templated_keys = sorted(
868
+ key
869
+ for key, value in config.items()
870
+ if not key.startswith('_') and is_unsubstituted_template(value)
871
+ )
872
+ for key in templated_keys:
873
+ os.environ.pop(key, None)
874
+ fallback = (
875
+ _configured_memory_dir(project_env.get(key), file_env.get(key))
876
+ if key == 'LAST30DAYS_MEMORY_DIR'
877
+ else merged_env.get(key)
878
+ )
879
+ # A lower-priority value that is itself a placeholder is not a credential.
880
+ if is_unsubstituted_template(fallback):
881
+ fallback = None
882
+ resolved = fallback if fallback is not None else declared_defaults.get(key)
883
+ config[key] = resolved if resolved is not None else ''
884
+ # The rotation ran before this sweep, so a fallback restored from a
885
+ # comma-separated list would otherwise reach a backend whole. Reapply it,
886
+ # then reject the picked key if it is itself a placeholder.
887
+ _rotate_scrapecreators_key(config)
888
+ if is_unsubstituted_template(config.get('SCRAPECREATORS_API_KEY')):
889
+ config['SCRAPECREATORS_API_KEY'] = ''
890
+ # Report only the keys still leaving the credential unset. A placeholder that
891
+ # fell through to a real lower-priority credential (or to a usable default)
892
+ # is handled, and reporting it would nag about a setup that works.
893
+ config[TEMPLATE_CONFIG_KEYS] = [
894
+ key for key in templated_keys if not config.get(key)
895
+ ]
896
+ if 'OPENAI_API_KEY' in templated_keys and not config.get('OPENAI_API_KEY'):
897
+ # Keep the derived auth record consistent with the token it describes.
898
+ config['OPENAI_AUTH_SOURCE'] = AUTH_SOURCE_NONE
899
+ config['OPENAI_AUTH_STATUS'] = AUTH_STATUS_MISSING
900
+
514
901
  if policy.browser_cookies == "read":
515
- browser_creds = extract_browser_credentials(config)
516
- for key, value in browser_creds.items():
517
- if not config.get(key):
518
- config[key] = value
519
- config[f"_{key}_SOURCE"] = "browser"
902
+ _discover_and_apply_x_credentials(config)
903
+
904
+ # Fixture recording (--record-fixtures) must redact a credential that
905
+ # came from a file, Keychain, or pass, not only one exported in the
906
+ # shell. No-op outside a recording session.
907
+ from . import http as _http
908
+ _http.add_fixture_redactions(_http.config_secret_values(config))
520
909
 
521
910
  return config
522
911
 
523
912
 
913
+ # ---------------------------------------------------------------------------
914
+ # Extra-host X cookie discovery (Linux, Mac mini, Darwin agentcookie sink)
915
+ # ---------------------------------------------------------------------------
916
+
917
+
918
+ def _mac_model() -> str:
919
+ """Darwin hardware model via ``sysctl -n hw.model``, or "" otherwise.
920
+
921
+ Returns "" on non-Darwin and on any sysctl failure (missing binary,
922
+ non-zero exit, timeout) — the caller treats "" as "not a Mac mini", i.e. a
923
+ MacBook, which is the conservative default (no extra cookie lookups).
924
+ """
925
+ import platform
926
+ if platform.system() != "Darwin":
927
+ return ""
928
+ import subprocess
929
+ try:
930
+ out = subprocess.run(
931
+ ["sysctl", "-n", "hw.model"],
932
+ capture_output=True, text=True, timeout=3,
933
+ )
934
+ except (OSError, subprocess.SubprocessError):
935
+ return ""
936
+ if out.returncode != 0:
937
+ return ""
938
+ return (out.stdout or "").strip()
939
+
940
+
941
+ def _is_mac_mini() -> bool:
942
+ """True on a Darwin Mac mini (``hw.model`` prefix ``Macmini``).
943
+
944
+ sysctl failure yields "" -> False, so an unreadable model is treated as a
945
+ MacBook (no extras), per the plan.
946
+ """
947
+ return _mac_model().startswith("Macmini")
948
+
949
+
950
+ def x_extras_enabled(config: dict[str, Any]) -> bool:
951
+ """Whether the two EXTRA bird cookie lookups (agentcookie sidecar, live
952
+ Chrome CDP) apply on this host.
953
+
954
+ Extras apply when ANY of:
955
+ * ``AGENTCOOKIE=on`` — explicit per-host opt-in (works on a MacBook too);
956
+ * platform is Linux;
957
+ * a Darwin Mac mini (``hw.model`` prefix ``Macmini``);
958
+ * a Darwin agentcookie **sink** role (parse failure = not sink).
959
+
960
+ A plain MacBook (Darwin, source/unknown role, no opt-in) stays on the
961
+ mainline path — no agentcookie subprocess, no CDP socket. The host is NEVER
962
+ inferred from the home directory, PATH, or ``HERMES_AGENT``/``OPENCLAW_CLI``
963
+ env: only the signals above.
964
+ """
965
+ import platform
966
+ raw = (config.get("AGENTCOOKIE") or read_secret_env("AGENTCOOKIE") or "").strip().lower()
967
+ if raw == "on":
968
+ return True
969
+ system = platform.system()
970
+ if system == "Linux":
971
+ return True
972
+ if system == "Darwin":
973
+ if _is_mac_mini():
974
+ return True
975
+ from . import agentcookie
976
+ return agentcookie.role_is_sink(config)
977
+ return False
978
+
979
+
980
+ def _apply_x_pair(config: dict[str, Any], auth_token: str, ct0: str, source: str) -> None:
981
+ """Apply a COMPLETE X cookie pair from one source, labeling its origin.
982
+
983
+ Atomic on purpose (both keys from the same source) so a half-pair from one
984
+ source is never merged with a half-pair from another. Never written to the
985
+ ``.env``; values are never logged.
986
+ """
987
+ config["AUTH_TOKEN"] = auth_token
988
+ config["CT0"] = ct0
989
+ config["_AUTH_TOKEN_SOURCE"] = source
990
+ config["_CT0_SOURCE"] = source
991
+
992
+
993
+ def _apply_browser_extract(config: dict[str, Any]) -> None:
994
+ """Run the mainline in-process browser cookie extractor (unchanged from
995
+ main): fills X (when a browser is opted in via FROM_BROWSER) and non-X
996
+ cookie domains like truthsocial. Missing keys only; source label ``browser``."""
997
+ browser_creds = extract_browser_credentials(config)
998
+ for key, value in browser_creds.items():
999
+ if not config.get(key):
1000
+ config[key] = value
1001
+ config[f"_{key}_SOURCE"] = "browser"
1002
+
1003
+
1004
+ def _discover_and_apply_x_credentials(config: dict[str, Any]) -> None:
1005
+ """Fill AUTH_TOKEN/CT0 for the bird backend, first COMPLETE pair wins.
1006
+
1007
+ Mainline (every host): the in-process browser extractor, gated by
1008
+ FROM_BROWSER exactly as on ``main``. EXTRA lookups (agentcookie sidecar,
1009
+ then live Chrome CDP) run ONLY on extra hosts (``x_extras_enabled``), so a
1010
+ MacBook with FROM_BROWSER unset/off does no agentcookie spawn and no CDP
1011
+ socket. Probe order:
1012
+
1013
+ 1. an explicit env AUTH_TOKEN+CT0 already present — never overwritten;
1014
+ 2. agentcookie sidecar (extras only);
1015
+ 3. live Chrome CDP (extras only);
1016
+ 4. the mainline browser extract (all hosts; X only when FROM_BROWSER
1017
+ lists a browser).
1018
+
1019
+ On a Mac mini that has already opted into browser reads (FROM_BROWSER set),
1020
+ the native extract runs BEFORE CDP (R19) — a local Keychain read beats a
1021
+ debug-port scrape. Never persists cookies; values are never logged.
1022
+
1023
+ On an official-only host (``x_policy``: ``LAST30DAYS_HOST=grok-bot``)
1024
+ this returns before ANY leg, for every cookie domain, unless the pin is
1025
+ ``bird`` (the one path that re-enables discovery for that run).
1026
+ """
1027
+ if not x_policy(config).cookie_discovery:
1028
+ return
1029
+
1030
+ from . import agentcookie, chrome_cdp
1031
+
1032
+ def have_pair() -> bool:
1033
+ return bool(config.get("AUTH_TOKEN") and config.get("CT0"))
1034
+
1035
+ extras = x_extras_enabled(config)
1036
+
1037
+ # (2) agentcookie sidecar — extras only, complete pair only.
1038
+ if extras and not have_pair():
1039
+ pair = agentcookie.read_x_cookies(config)
1040
+ if pair:
1041
+ _apply_x_pair(config, pair["auth_token"], pair["ct0"], "agentcookie")
1042
+
1043
+ # Mac mini + browser opted in: native extract before CDP (R19).
1044
+ mini_extract_first = (
1045
+ extras and _is_mac_mini() and bool(cookie_extraction_browsers(config))
1046
+ )
1047
+ if mini_extract_first and not have_pair():
1048
+ _apply_browser_extract(config)
1049
+
1050
+ # (3) live Chrome CDP — extras only, after browser-cookie consent.
1051
+ if extras and not have_pair() and chrome_cdp.cookie_access_allowed(config):
1052
+ pair = chrome_cdp.read_x_cookies(config)
1053
+ if pair:
1054
+ _apply_x_pair(config, pair["auth_token"], pair["ct0"], "chrome cdp")
1055
+
1056
+ # (4) mainline browser extract (unless already run above for the mini case).
1057
+ if not mini_extract_first:
1058
+ _apply_browser_extract(config)
1059
+
1060
+
524
1061
  # ---------------------------------------------------------------------------
525
1062
  # Browser cookie extraction
526
1063
  # ---------------------------------------------------------------------------
@@ -538,6 +1075,10 @@ COOKIE_DOMAINS: dict[str, dict[str, Any]] = {
538
1075
  },
539
1076
  }
540
1077
 
1078
+ COOKIE_BROWSER_NAMES = (
1079
+ "firefox", "safari", "chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"
1080
+ )
1081
+
541
1082
 
542
1083
  def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]:
543
1084
  """Browsers to try for cookie extraction, honoring FROM_BROWSER.
@@ -559,11 +1100,17 @@ def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]:
559
1100
 
560
1101
  Returning the browser list from one place keeps the setup wizard and the
561
1102
  steady-state path on the same policy, so neither surprises the user with an
562
- unrequested Keychain prompt.
1103
+ unrequested Keychain prompt. On an official-only host (``x_policy``) the
1104
+ list is empty regardless of ``FROM_BROWSER`` unless ``bird`` is pinned.
563
1105
  """
564
- silent_browsers = ["firefox", "safari"]
565
- chromium_browsers = ["chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"]
566
- known_browsers = silent_browsers + chromium_browsers
1106
+ if not x_policy(config).cookie_discovery:
1107
+ return []
1108
+ consent = config.get("BROWSER_CONSENT")
1109
+ if consent is not None and str(consent).strip().lower() not in {"1", "true", "yes", "on"}:
1110
+ return []
1111
+ silent_browsers = list(COOKIE_BROWSER_NAMES[:2])
1112
+ chromium_browsers = list(COOKIE_BROWSER_NAMES[2:])
1113
+ known_browsers = list(COOKIE_BROWSER_NAMES)
567
1114
  from_browser = (config.get("FROM_BROWSER") or "").strip().lower()
568
1115
  if not from_browser:
569
1116
  return []
@@ -613,32 +1160,86 @@ def extract_browser_credentials(config: dict[str, Any]) -> dict[str, str]:
613
1160
  return {}
614
1161
  extracted: dict[str, str] = {}
615
1162
  for _service, spec in COOKIE_DOMAINS.items():
616
- if all(config.get(env_key) for env_key in spec["mapping"].values()):
1163
+ missing_cookies = [
1164
+ name for name in spec["cookies"] if not config.get(spec["mapping"][name])
1165
+ ]
1166
+ if not missing_cookies:
617
1167
  continue
1168
+ # Cookies from different browsers can belong to different sessions,
1169
+ # so values are never combined across browsers: a complete set from
1170
+ # one browser wins, else the first browser's partial set is kept.
1171
+ chosen: dict[str, str] | None = None
1172
+ fallback: dict[str, str] | None = None
1173
+ denied_browsers: list[str] = []
618
1174
  for browser in browsers:
619
1175
  try:
620
1176
  cookies = cookie_extract.extract_cookies(browser, spec["domain"], spec["cookies"])
1177
+ except PermissionError:
1178
+ denied_browsers.append(browser)
1179
+ if len(missing_cookies) == len(spec["cookies"]):
1180
+ continue
1181
+ # Keep full-pair profile preference unless a denied profile blocks it.
1182
+ try:
1183
+ cookies = cookie_extract.extract_cookies(
1184
+ browser, spec["domain"], missing_cookies
1185
+ )
1186
+ except Exception:
1187
+ continue
621
1188
  except Exception:
622
1189
  continue
623
- if cookies:
624
- for cookie_name, env_key in spec["mapping"].items():
625
- if cookie_name in cookies and not config.get(env_key):
626
- extracted[env_key] = cookies[cookie_name]
627
- break # Found cookies for this service, stop trying browsers
1190
+ if not cookies:
1191
+ continue
1192
+ if cookie_extract.has_complete_pair(cookies, spec["cookies"]):
1193
+ chosen = cookies
1194
+ break
1195
+ if fallback is None:
1196
+ fallback = cookies
1197
+ if chosen is None:
1198
+ chosen = fallback or {}
1199
+ if _service == "x" and denied_browsers and not cookie_extract.has_complete_pair(
1200
+ chosen, spec["cookies"]
1201
+ ):
1202
+ sys.stderr.write(
1203
+ "[last30days] X browser cookie access permission denied in "
1204
+ f"{', '.join(denied_browsers)}. {X_COOKIE_ACCESS_FIX}\n"
1205
+ )
1206
+ for cookie_name, env_key in spec["mapping"].items():
1207
+ if chosen.get(cookie_name) and not config.get(env_key):
1208
+ extracted[env_key] = chosen[cookie_name]
628
1209
  return extracted
629
1210
 
630
1211
 
1212
+ # Auth-origin label per X backend for ``get_x_source_with_method`` (bird's
1213
+ # label is the cookie source recorded in ``_AUTH_TOKEN_SOURCE``).
1214
+ _X_METHOD_LABELS = {
1215
+ "xai": "xai",
1216
+ "xurl": "oauth2", # xurl CLI (official X API v2, OAuth2, free developer app)
1217
+ "xapi": "bearer",
1218
+ "xquik": "api_key",
1219
+ }
1220
+
1221
+
631
1222
  def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
632
- """Return (source, method) for X search, where method describes the auth origin."""
633
- if config.get("XAI_API_KEY"):
634
- return "xai", "xai"
635
- if config.get("AUTH_TOKEN") and config.get("CT0"):
636
- method = config.get("_AUTH_TOKEN_SOURCE", "env")
637
- return "bird", method
638
- # Fall back to xurl CLI (official X API v2, OAuth2, free developer app)
639
- from . import xurl_x
640
- if xurl_x.is_available():
641
- return "xurl", "oauth2"
1223
+ """Return (source, method) for X search, where method describes the auth origin.
1224
+
1225
+ Walks the policy's unpinned auto chain (``x_auto_chain``): on a default
1226
+ host bird first (cookies beat XAI_API_KEY when both are present), then
1227
+ xai, xurl, xquik; on an official-only host xapi, xai, xurl. Opt-in
1228
+ backends (grok, and xapi off Grok Bot) are never auto-selected here.
1229
+ """
1230
+ has_bird_creds = bool(config.get("AUTH_TOKEN") and config.get("CT0"))
1231
+ for backend in x_auto_chain(config):
1232
+ if backend == "bird":
1233
+ # Cookie presence only: the scraper install is not consulted
1234
+ # here (unlike ``x_backend_chain``), so a fresh cookie-bearing
1235
+ # config reports bird before the binary is checked.
1236
+ if not has_bird_creds:
1237
+ continue
1238
+ elif not _x_backend_available(backend, config, has_bird_creds):
1239
+ continue
1240
+ if backend == "bird":
1241
+ return "bird", config.get("_AUTH_TOKEN_SOURCE", "env")
1242
+ return backend, _X_METHOD_LABELS.get(backend, "none")
642
1243
  return None, "none"
643
1244
 
644
1245
 
@@ -667,28 +1268,227 @@ def get_reddit_source(config: dict[str, Any]) -> str | None:
667
1268
  # source; the rest are ordered failover backups, tried only if the one before
668
1269
  # returns nothing or errors. There is one X source ("x"); these are its
669
1270
  # interchangeable backends, never run in parallel.
670
- # xai — xAI/Grok live search (XAI_API_KEY)
671
1271
  # bird — X GraphQL scrape via the user's browser cookies (AUTH_TOKEN/CT0)
1272
+ # xai — xAI/Grok live search (XAI_API_KEY)
672
1273
  # xurl — official X API v2 (xurl CLI, OAuth2)
673
- # xquik — key-based REST X search (XQUIK_API_KEY); keyless of browser cookies
674
- _X_BACKEND_ORDER = ("xai", "bird", "xurl", "xquik")
1274
+ # xquik — key-based REST X search (XQUIK_API_KEY)
1275
+ _X_BACKEND_ORDER = ("bird", "xai", "xurl", "xquik")
1276
+
1277
+ # Opt-in backends: never in the default unpinned auto chain; require an
1278
+ # explicit pin. grok is here because a leftover ~/.grok/auth.json must never
1279
+ # steal the X lane. xapi (direct X API v2 with X_BEARER_TOKEN) is here so an
1280
+ # ambient bearer exported for some other tool never spends X API credits
1281
+ # every time the cookie scraper comes back empty; on an official-only
1282
+ # host it is the first rung of the auto chain instead (see _X_OFFICIAL).
1283
+ _X_BACKEND_OPT_IN = ("grok", "xapi")
1284
+
1285
+ # All known backends (auto chain + opt-in): valid values for the pin var.
1286
+ _X_BACKEND_KNOWN = _X_BACKEND_ORDER + _X_BACKEND_OPT_IN
1287
+
1288
+ # Licensed / official backends: the unpinned auto chain on an official-only
1289
+ # host. xapi = X API v2 with an app-only bearer, xai = xAI's licensed
1290
+ # X search, xurl = the X API through X's own CLI.
1291
+ _X_OFFICIAL = ("xapi", "xai", "xurl")
1292
+
1293
+ # Host self-identification key and the one value that switches the X
1294
+ # policy. The engine trusts this key alone: it never infers the host from
1295
+ # the home directory, PATH, platform, or agent env vars.
1296
+ X_HOST_VAR = 'LAST30DAYS_HOST'
1297
+ GROK_BOT_HOST = 'grok-bot'
1298
+ # Per-session X connector lane signal: process env only.
1299
+ X_HOST_LANE_VAR = 'LAST30DAYS_X_HOST_LANE'
1300
+
1301
+ # Agent-hosted run detection, used only to enforce SKILL.md LAW 7 (the host
1302
+ # model writes the query plan and passes --plan). This is separate from the
1303
+ # X policy above, which trusts LAST30DAYS_HOST alone. Here the engine reads
1304
+ # the markers agent runtimes export into the shells they spawn, because the
1305
+ # failure it guards against is a host that forgot to plan. Process
1306
+ # environment only: a .env line never makes a cron run look agent-hosted.
1307
+ AGENT_HOST_ENV_VARS = (
1308
+ 'CLAUDECODE', # Claude Code
1309
+ 'CLAUDE_CODE_ENTRYPOINT', # Claude Code / Claude Agent SDK
1310
+ 'CODEX_THREAD_ID', # Codex
1311
+ 'CODEX_SESSION_ID',
1312
+ 'CODEX_SANDBOX',
1313
+ )
1314
+ # Explicit self-identification for any other agent runtime.
1315
+ HOST_AGENT_VAR = 'LAST30DAYS_HOST_AGENT'
1316
+ # Headless/cron escape hatch under an agent: let the engine plan internally.
1317
+ ALLOW_ENGINE_PLAN_VAR = 'LAST30DAYS_ALLOW_ENGINE_PLAN'
1318
+
1319
+
1320
+ def agent_host_signal(environ: Any = None) -> str:
1321
+ """Name of the env var that marks this process as agent-hosted, or "".
1322
+
1323
+ ``LAST30DAYS_HOST_AGENT`` counts when truthy; the runtime markers in
1324
+ ``AGENT_HOST_ENV_VARS`` and a host self-identification in
1325
+ ``LAST30DAYS_HOST`` (e.g. ``grok-bot``) count when non-empty. Returns the
1326
+ variable name (never its value) so messages can cite what was detected.
1327
+ """
1328
+ source = os.environ if environ is None else environ
1329
+ if _truthy(source.get(HOST_AGENT_VAR)):
1330
+ return HOST_AGENT_VAR
1331
+ for name in (*AGENT_HOST_ENV_VARS, X_HOST_VAR):
1332
+ if str(source.get(name) or '').strip():
1333
+ return name
1334
+ return ''
1335
+
1336
+
1337
+ def agent_hosted_run(environ: Any = None) -> bool:
1338
+ """True when an agent runtime appears to be hosting this engine process."""
1339
+ return bool(agent_host_signal(environ))
1340
+
1341
+
1342
+ def engine_plan_allowed(environ: Any = None) -> bool:
1343
+ """True when ``LAST30DAYS_ALLOW_ENGINE_PLAN`` opts back into engine planning."""
1344
+ source = os.environ if environ is None else environ
1345
+ return _truthy(source.get(ALLOW_ENGINE_PLAN_VAR))
1346
+
1347
+ # Public routing definitions for the doctor/backend-descriptor layer
1348
+ # (lib/backends.py). These are aliases for knowledge this module already
1349
+ # owns — the declared X chain order and the pin/floor env var names — so
1350
+ # descriptors import one source of truth instead of restating it.
1351
+ X_BACKEND_ORDER = _X_BACKEND_ORDER
1352
+ X_BACKEND_OPT_IN = _X_BACKEND_OPT_IN
1353
+ X_BACKEND_KNOWN = _X_BACKEND_KNOWN
1354
+ X_OFFICIAL = _X_OFFICIAL
1355
+ X_BACKEND_PIN_VAR = 'LAST30DAYS_X_BACKEND'
1356
+ REDDIT_BACKEND_PIN_VAR = 'LAST30DAYS_REDDIT_BACKEND'
1357
+ REDDIT_SC_MIN_ITEMS_VAR = 'LAST30DAYS_REDDIT_SC_MIN_ITEMS'
1358
+ # Keyed runs backfill Reddit from ScrapeCreators when the free path returns
1359
+ # fewer than this many items. Thin topics yield 2-3 free results; healthy
1360
+ # topics many more, so 5 spends credits only where it adds coverage.
1361
+ REDDIT_SC_MIN_ITEMS_DEFAULT = 5
1362
+ YOUTUBE_SC_MIN_ITEMS_VAR = 'LAST30DAYS_YT_SC_MIN_ITEMS'
1363
+ YOUTUBE_SC_MIN_ITEMS_DEFAULT = 3
1364
+
1365
+
1366
+ def reddit_sc_min_items(config: dict[str, Any]) -> int:
1367
+ """The Reddit ScrapeCreators backfill floor, parsed one way for every caller.
1368
+
1369
+ Unset or blank means ``REDDIT_SC_MIN_ITEMS_DEFAULT``; an explicit ``0``
1370
+ means backfill only when the free path is empty; a malformed value means
1371
+ ``0`` so a typo never spends extra credits. Negative values clamp to 0.
1372
+ """
1373
+ raw = config.get(REDDIT_SC_MIN_ITEMS_VAR)
1374
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
1375
+ return REDDIT_SC_MIN_ITEMS_DEFAULT
1376
+ try:
1377
+ return max(int(raw), 0)
1378
+ except (TypeError, ValueError):
1379
+ return 0
1380
+
1381
+
1382
+ def youtube_sc_min_items(config: dict[str, Any]) -> int:
1383
+ """Minimum yt-dlp result count before keyed YouTube search backfill is skipped."""
1384
+ raw = config.get(YOUTUBE_SC_MIN_ITEMS_VAR)
1385
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
1386
+ return YOUTUBE_SC_MIN_ITEMS_DEFAULT
1387
+ try:
1388
+ return max(int(raw), 0)
1389
+ except (TypeError, ValueError):
1390
+ return 0
675
1391
 
676
1392
 
677
- def _x_backend_available(backend: str, config: dict[str, Any], has_bird_creds: bool) -> bool:
1393
+ @dataclass(frozen=True)
1394
+ class XPolicy:
1395
+ """The host-conditional X routing rule, resolved once per config.
1396
+
1397
+ ``host`` is the normalized ``LAST30DAYS_HOST`` value; ``official_only``
1398
+ is true on a Grok Bot host; ``auto_chain`` is the unpinned chain
1399
+ (``_X_OFFICIAL`` when official-only, else ``_X_BACKEND_ORDER``);
1400
+ ``cookie_discovery`` is false when official-only unless the pin is
1401
+ ``bird``; ``hint_namespace`` (``official`` or ``default``) is derived
1402
+ from ``official_only`` as a plain string so this module never imports
1403
+ ``prescriptions``.
1404
+ """
1405
+
1406
+ host: str
1407
+ official_only: bool
1408
+ auto_chain: tuple[str, ...]
1409
+ cookie_discovery: bool
1410
+
1411
+ @property
1412
+ def hint_namespace(self) -> str:
1413
+ return 'official' if self.official_only else 'default'
1414
+
1415
+
1416
+ def x_backend_pin(config: dict[str, Any]) -> str:
1417
+ """The normalized ``LAST30DAYS_X_BACKEND`` pin value ("" when unset)."""
1418
+ return (config.get(X_BACKEND_PIN_VAR) or '').strip().lower()
1419
+
1420
+
1421
+ def x_policy(config: dict[str, Any]) -> XPolicy:
1422
+ """Resolve the X policy from ``LAST30DAYS_HOST`` and the pin.
1423
+
1424
+ This is the ONLY place the Grok Bot host string is compared. It reads
1425
+ just the host key, the pin, and the config dict: no platform, PATH, home
1426
+ directory, or agent env-var inspection (the same rule ``x_extras_enabled``
1427
+ follows), and nothing imported from ``lib``. The pin keeps its exclusive
1428
+ semantics on every host and may name any known backend; a ``bird`` pin
1429
+ is the one path that re-enables cookie discovery on an official-only host.
1430
+ """
1431
+ host = str(config.get(X_HOST_VAR) or '').strip().lower()
1432
+ official_only = host == GROK_BOT_HOST
1433
+ pin = x_backend_pin(config)
1434
+ return XPolicy(
1435
+ host=host,
1436
+ official_only=official_only,
1437
+ auto_chain=_X_OFFICIAL if official_only else _X_BACKEND_ORDER,
1438
+ cookie_discovery=(not official_only) or pin == 'bird',
1439
+ )
1440
+
1441
+
1442
+ def x_auto_chain(config: dict[str, Any]) -> list[str]:
1443
+ """The unpinned X auto chain for this host, in failover order."""
1444
+ return list(x_policy(config).auto_chain)
1445
+
1446
+
1447
+ def x_host_lane_declared(config: dict[str, Any]) -> bool:
1448
+ """True when the hosting model declared the X connector lane.
1449
+
1450
+ ``get_config`` fills ``LAST30DAYS_X_HOST_LANE`` from the process
1451
+ environment only, so a ``.env`` line never declares the lane.
1452
+ Deliberately NOT ``x_pending_browser_auth``: that predicate is false in
1453
+ cookie-read mode by contract, which would leave the envelope path dead at
1454
+ research time. Host-independent: the envelope is accepted anywhere.
1455
+ """
1456
+ return _truthy(config.get(X_HOST_LANE_VAR))
1457
+
1458
+
1459
+ def _x_backend_available(
1460
+ backend: str,
1461
+ config: dict[str, Any],
1462
+ has_bird_creds: bool,
1463
+ local_only: bool = False,
1464
+ ) -> bool:
678
1465
  if backend == 'xai':
679
1466
  return bool(config.get('XAI_API_KEY'))
1467
+ if backend == 'grok':
1468
+ # Keyless relative to X: needs only an installed, signed-in grok CLI.
1469
+ # Both surfaces are filesystem-only (PATH lookup + credential store),
1470
+ # so local_only needs no separate branch.
1471
+ from . import grok_x
1472
+ return grok_x.has_stored_auth()
680
1473
  if backend == 'bird':
681
1474
  from . import bird_x
682
1475
  return has_bird_creds and bird_x.is_bird_installed()
683
1476
  if backend == 'xurl':
684
1477
  from . import xurl_x
1478
+ if local_only:
1479
+ # Doctor/safe-diagnose path: local evidence only (PATH lookup +
1480
+ # token store) — never the live `xurl whoami` network call.
1481
+ return xurl_x.has_stored_auth()
685
1482
  return xurl_x.is_available()
686
1483
  if backend == 'xquik':
687
1484
  return is_xquik_available(config)
1485
+ if backend == 'xapi':
1486
+ # Key presence only (no network); local_only needs no branch.
1487
+ return bool(config.get('X_BEARER_TOKEN'))
688
1488
  return False
689
1489
 
690
1490
 
691
- def x_backend_chain(config: dict[str, Any]) -> list[str]:
1491
+ def x_backend_chain(config: dict[str, Any], local_only: bool = False) -> list[str]:
692
1492
  """Ordered list of available X backends.
693
1493
 
694
1494
  ``chain[0]`` is the default X source; the remaining entries are failover
@@ -696,33 +1496,61 @@ def x_backend_chain(config: dict[str, Any]) -> list[str]:
696
1496
  exactly one X source — these are its backends, never fetched in parallel.
697
1497
 
698
1498
  A ``LAST30DAYS_X_BACKEND`` pin forces a single backend (no failover): the
699
- user explicitly chose it. Browser-cookie probing is intentionally avoided
700
- (automatic Keychain access causes popups); bird counts as available only
701
- when AUTH_TOKEN and CT0 are present explicitly.
1499
+ user explicitly chose it. Valid pin values are in ``_X_BACKEND_KNOWN``
1500
+ (the auto chain plus opt-in backends like grok). Browser-cookie probing
1501
+ is intentionally avoided (automatic Keychain access causes popups); bird
1502
+ counts as available only when AUTH_TOKEN and CT0 are present explicitly.
1503
+
1504
+ Unpinned runs walk only ``_X_BACKEND_ORDER``: opt-in backends like grok
1505
+ are never auto-selected. A leftover ~/.grok/auth.json must not steal the
1506
+ X lane; pin ``LAST30DAYS_X_BACKEND=grok`` to enable it explicitly.
1507
+
1508
+ ``local_only=True`` is the doctor/safe-diagnose flavor: availability is
1509
+ answered from local evidence only (no subprocess spawns that reach the
1510
+ network — xurl's live `whoami` check is replaced by its on-disk token
1511
+ store). Research-time callers keep the default live semantics.
1512
+
1513
+ The unpinned walk is ``x_policy(config).auto_chain``: the default order
1514
+ above on every host, or ``_X_OFFICIAL`` (xapi -> xai -> xurl) on an
1515
+ official-only host. The scraper is primed with cookies only when bird
1516
+ ends up in the resulting chain (never on an official-only host unless
1517
+ bird is pinned).
702
1518
  """
703
- from . import bird_x
704
1519
  has_bird_creds = bool(config.get('AUTH_TOKEN') and config.get('CT0'))
705
- if has_bird_creds:
706
- bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
707
-
708
- preferred = (config.get('LAST30DAYS_X_BACKEND') or '').lower()
709
- if preferred in _X_BACKEND_ORDER:
710
- return [preferred] if _x_backend_available(preferred, config, has_bird_creds) else []
711
1520
 
712
- return [b for b in _X_BACKEND_ORDER if _x_backend_available(b, config, has_bird_creds)]
1521
+ preferred = x_backend_pin(config)
1522
+ # Pin accepted from _X_BACKEND_KNOWN (auto chain + opt-in like grok).
1523
+ if preferred in _X_BACKEND_KNOWN:
1524
+ if _x_backend_available(preferred, config, has_bird_creds, local_only):
1525
+ chain = [preferred]
1526
+ else:
1527
+ chain = []
1528
+ else:
1529
+ # Unpinned: walk the policy's auto chain. Opt-in backends (grok, and
1530
+ # xapi off an official-only host) are never auto-selected.
1531
+ chain = [
1532
+ b for b in x_policy(config).auto_chain
1533
+ if _x_backend_available(b, config, has_bird_creds, local_only)
1534
+ ]
1535
+
1536
+ if 'bird' in chain:
1537
+ from . import bird_x
1538
+ bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
1539
+ return chain
713
1540
 
714
1541
 
715
- def get_x_source(config: dict[str, Any]) -> str | None:
1542
+ def get_x_source(config: dict[str, Any], local_only: bool = False) -> str | None:
716
1543
  """The default (primary) X backend, or None if no X source is available.
717
1544
 
718
1545
  Thin wrapper over ``x_backend_chain`` returning the first/primary backend;
719
1546
  callers that want failover should use ``x_backend_chain`` directly.
1547
+ ``local_only`` is forwarded (see ``x_backend_chain``).
720
1548
  """
721
- chain = x_backend_chain(config)
1549
+ chain = x_backend_chain(config, local_only=local_only)
722
1550
  return chain[0] if chain else None
723
1551
 
724
1552
 
725
- def x_pending_browser_auth(config: dict[str, Any]) -> bool:
1553
+ def x_pending_browser_auth(config: dict[str, Any], local_only: bool = False) -> bool:
726
1554
  """True when X is not available now but ``FROM_BROWSER`` will authenticate it at run time.
727
1555
 
728
1556
  ``--diagnose`` / ``--preflight`` load config in ``plan_only`` mode, which
@@ -731,28 +1559,48 @@ def x_pending_browser_auth(config: dict[str, Any]) -> bool:
731
1559
  dropped from ``available_sources`` even though a normal run would extract the
732
1560
  same cookies and authenticate X fine. This predicate reports that
733
1561
  "available pending browser auth" state without reading a single cookie — it
734
- keys only on the already-resolved browser list (``cookie_extraction_browsers``
735
- derives it from ``FROM_BROWSER`` alone, no secrets), bird being installed, and
736
- X having a cookie-domain mapping. Side-effect free, so the safe-inspection
737
- contract of diagnose/preflight is preserved.
1562
+ keys only on the resolved browser list (``cookie_extraction_browsers``
1563
+ derives it from ``FROM_BROWSER`` alone, no secrets) OR — on extra hosts
1564
+ only (``x_extras_enabled``) — the agentcookie sidecar being on PATH (a plain
1565
+ ``which`` lookup), bird being installed, and X having a cookie-domain
1566
+ mapping. A plain MacBook must NOT predict bird from an agentcookie binary on
1567
+ PATH (R18), so the sidecar leg is gated behind ``x_extras_enabled``.
1568
+ Side-effect free, so the safe-inspection contract of diagnose/preflight is
1569
+ preserved.
738
1570
 
739
1571
  Returns False whenever X is already available outright (static AUTH_TOKEN/CT0,
740
1572
  or xAI/xurl/xquik backend), and in ``read`` mode (a real run has already
741
1573
  extracted creds, so its status must be unchanged — never "pending").
742
1574
  """
743
1575
  # Already available via a static backend (bird creds, xAI, xurl, xquik).
744
- if get_x_source(config):
1576
+ # local_only (doctor/safe-diagnose) answers the xurl leg from the token
1577
+ # store instead of the live `xurl whoami` network call.
1578
+ if get_x_source(config, local_only=local_only):
745
1579
  return False
746
1580
  # Only meaningful in inspection modes that skip extraction; a real ``read``
747
1581
  # run has already attempted extraction and must report its true state.
748
1582
  if config.get('_BROWSER_COOKIE_MODE') == 'read':
749
1583
  return False
750
- if 'x' not in COOKIE_DOMAINS:
1584
+ # Cookie-only predicate: on an official-only host no run-time cookie
1585
+ # source exists unless bird is pinned (x_policy), so nothing is pending.
1586
+ if not x_policy(config).cookie_discovery:
751
1587
  return False
752
- if not cookie_extraction_browsers(config):
1588
+ if 'x' not in COOKIE_DOMAINS:
753
1589
  return False
754
1590
  from . import bird_x
755
- return bird_x.is_bird_installed()
1591
+ if not bird_x.is_bird_installed():
1592
+ return False
1593
+ # A FROM_BROWSER browser is a run-time cookie source on any host.
1594
+ if cookie_extraction_browsers(config):
1595
+ return True
1596
+ # The agentcookie sidecar is a run-time cookie source ONLY on extra hosts
1597
+ # (Linux / Mac mini / Darwin sink / AGENTCOOKIE=on). Gating this keeps a
1598
+ # plain MacBook from predicting bird off a stray agentcookie binary (R18).
1599
+ if x_extras_enabled(config):
1600
+ from . import agentcookie
1601
+ if agentcookie.is_available(config):
1602
+ return True
1603
+ return False
756
1604
 
757
1605
 
758
1606
  def is_ytdlp_available() -> bool:
@@ -764,17 +1612,23 @@ def is_ytdlp_available() -> bool:
764
1612
  def is_youtube_comments_available(config: dict[str, Any]) -> bool:
765
1613
  """Check if YouTube comment enrichment is available.
766
1614
 
767
- Default-on when SCRAPECREATORS_API_KEY is set — the same key-only backup
768
- tier as the YouTube transcript fallback (``is_youtube_sc_available``). Cost
769
- is bounded by ``enrich_with_comments(max_videos=3)`` (~3 credits per run).
770
- Suppress via ``EXCLUDE_SOURCES=youtube_comments``.
1615
+ yt-dlp fetches YouTube comments free and keyless, so when it is installed
1616
+ comments need no credential and no ``INCLUDE_SOURCES`` opt-in — the opt-in
1617
+ only ever existed to gate ScrapeCreators credit spend, and there is none to
1618
+ gate. ``EXCLUDE_SOURCES=youtube_comments`` remains the off-switch.
771
1619
 
772
- Note: TikTok/Instagram comments remain explicit ``INCLUDE_SOURCES`` opt-ins
773
- (see ``is_tiktok_comments_available``); only YouTube comments are default-on.
1620
+ Without yt-dlp, the legacy ScrapeCreators path still applies: it requires
1621
+ SCRAPECREATORS_API_KEY AND ``youtube_comments`` in ``INCLUDE_SOURCES``
1622
+ (mirroring ``is_tiktok_comments_available``), bounded by
1623
+ ``enrich_with_comments(max_videos=3)`` at ~3 credits per run.
774
1624
  """
1625
+ if 'youtube_comments' in _parse_exclude_sources(config):
1626
+ return False
1627
+ if is_ytdlp_available():
1628
+ return True
775
1629
  if not config.get('SCRAPECREATORS_API_KEY'):
776
1630
  return False
777
- return 'youtube_comments' not in _parse_exclude_sources(config)
1631
+ return 'youtube_comments' in _parse_include_sources(config)
778
1632
 
779
1633
 
780
1634
  def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
@@ -789,6 +1643,20 @@ def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
789
1643
  return 'tiktok_comments' in include
790
1644
 
791
1645
 
1646
+ def is_instagram_comments_available(config: dict[str, Any]) -> bool:
1647
+ """Check if Instagram comment enrichment is available.
1648
+
1649
+ Requires SCRAPECREATORS_API_KEY AND instagram_comments in INCLUDE_SOURCES.
1650
+ Mirrors the youtube_comments / tiktok_comments opt-in pattern. Comments are
1651
+ fetched via ScrapeCreators (GET /v2/instagram/post/comments) with each
1652
+ comment's ``comment_like_count`` used as its vote for ranking. Part of the
1653
+ default onboarding tier (posts on -> comments on for TikTok/Instagram/YouTube).
1654
+ """
1655
+ if not config.get('SCRAPECREATORS_API_KEY'):
1656
+ return False
1657
+ return 'instagram_comments' in _parse_include_sources(config)
1658
+
1659
+
792
1660
  def is_youtube_sc_available(config: dict[str, Any]) -> bool:
793
1661
  """Check if ScrapeCreators YouTube search fallback is available.
794
1662
 
@@ -894,13 +1762,31 @@ def _parse_exclude_sources(config: dict[str, Any]) -> set[str]:
894
1762
  return {s.strip().lower() for s in raw.split(',') if s.strip()}
895
1763
 
896
1764
 
1765
+ def include_sources(config: dict[str, Any]) -> set[str]:
1766
+ """Public view of the parsed INCLUDE_SOURCES set.
1767
+
1768
+ Thin wrapper over ``_parse_include_sources`` so other modules (doctor,
1769
+ etc.) don't reach into env's privates.
1770
+ """
1771
+ return _parse_include_sources(config)
1772
+
1773
+
1774
+ def is_setup_complete(config: dict[str, Any]) -> bool:
1775
+ """Whether guided setup marked this config complete (SETUP_COMPLETE truthy).
1776
+
1777
+ Thin wrapper over ``_truthy`` so other modules don't reach into env's
1778
+ privates.
1779
+ """
1780
+ return _truthy(config.get('SETUP_COMPLETE'))
1781
+
1782
+
897
1783
  def is_threads_available(config: dict[str, Any]) -> bool:
898
- """Check if Threads source is available.
1784
+ """Check if the Threads credential is available.
899
1785
 
900
- Returns True when SCRAPECREATORS_API_KEY is set. Threads runs alongside
901
- TikTok and Instagram as part of the SC family — same key, same per-call
902
- cost shape, so the same default-on rule applies. Suppress via
903
- EXCLUDE_SOURCES=threads.
1786
+ Returns True when SCRAPECREATORS_API_KEY is set. This is an availability
1787
+ predicate only: whether Threads is actually *scheduled* is gated in the
1788
+ pipeline's ``available_sources`` by an ``INCLUDE_SOURCES=threads`` opt-in
1789
+ (the onboarding "Everything" tier), so a key alone no longer runs Threads.
904
1790
  """
905
1791
  return bool(config.get('SCRAPECREATORS_API_KEY'))
906
1792
 
@@ -922,9 +1808,52 @@ def get_instagram_token(config: dict[str, Any]) -> str:
922
1808
  def get_xiaohongshu_api_base(config: dict[str, Any]) -> str:
923
1809
  """Get Xiaohongshu HTTP API base URL.
924
1810
 
925
- Defaults to host.docker.internal so OpenClaw Docker can reach host service.
1811
+ The availability probe caches the first logged-in local service it finds so
1812
+ the later search request uses the same browser-backed session endpoint.
926
1813
  """
927
- return (config.get('XIAOHONGSHU_API_BASE') or "http://host.docker.internal:18060").rstrip("/")
1814
+ cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
1815
+ if cached:
1816
+ return str(cached).rstrip("/")
1817
+
1818
+ explicit = config.get("XIAOHONGSHU_API_BASE")
1819
+ if explicit:
1820
+ return str(explicit).rstrip("/")
1821
+
1822
+ return XIAOHONGSHU_DEFAULT_API_BASES[0]
1823
+
1824
+
1825
+ def _xiaohongshu_api_base_candidates(config: dict[str, Any]) -> list[str]:
1826
+ explicit = config.get("XIAOHONGSHU_API_BASE")
1827
+ if explicit:
1828
+ return [str(explicit).rstrip("/")]
1829
+
1830
+ candidates: list[str] = []
1831
+ cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
1832
+ if cached:
1833
+ candidates.append(str(cached).rstrip("/"))
1834
+
1835
+ for base in XIAOHONGSHU_DEFAULT_API_BASES:
1836
+ if base not in candidates:
1837
+ candidates.append(base)
1838
+ return candidates
1839
+
1840
+
1841
+ def _xiaohongshu_base_logged_in(base: str, http_module: Any) -> bool:
1842
+ # Keep the health probe snappy, but allow one retry for transient hiccups.
1843
+ health = http_module.get(f"{base}/health", timeout=3, retries=2)
1844
+ if not isinstance(health, dict):
1845
+ return False
1846
+ if not health.get("success"):
1847
+ return False
1848
+
1849
+ # Login checks can be slower because some services consult the browser
1850
+ # profile/session, so use a slightly longer timeout than the health probe.
1851
+ login = http_module.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
1852
+ is_logged_in = (
1853
+ login.get("data", {}).get("is_logged_in")
1854
+ if isinstance(login, dict) else False
1855
+ )
1856
+ return bool(is_logged_in)
928
1857
 
929
1858
 
930
1859
  def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
@@ -932,32 +1861,20 @@ def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
932
1861
  # Import here to avoid heavy imports at module load.
933
1862
  from . import http
934
1863
 
935
- base = get_xiaohongshu_api_base(config)
936
- try:
937
- # Keep health probe snappy, but allow one retry for transient hiccups.
938
- health = http.get(f"{base}/health", timeout=3, retries=2)
939
- if not isinstance(health, dict):
940
- return False
941
- if not health.get("success"):
942
- return False
943
-
944
- # Login probe can be slower on some deployments (browser/session checks),
945
- # so use a slightly longer timeout to avoid false negatives.
946
- login = http.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
947
- is_logged_in = (
948
- login.get("data", {}).get("is_logged_in")
949
- if isinstance(login, dict) else False
950
- )
951
- return bool(is_logged_in)
952
- except (OSError, http.HTTPError):
953
- return False
954
- except Exception as exc:
955
- sys.stderr.write(
956
- f"[last30days] WARNING: unexpected error checking Xiaohongshu: "
957
- f"{type(exc).__name__}: {exc}\n"
958
- )
959
- sys.stderr.flush()
960
- return False
1864
+ for base in _xiaohongshu_api_base_candidates(config):
1865
+ try:
1866
+ if _xiaohongshu_base_logged_in(base, http):
1867
+ config[XIAOHONGSHU_RESOLVED_API_BASE_KEY] = base
1868
+ return True
1869
+ except (OSError, http.HTTPError):
1870
+ continue
1871
+ except Exception as exc:
1872
+ sys.stderr.write(
1873
+ f"[last30days] WARNING: unexpected error checking Xiaohongshu "
1874
+ f"at {base}: {type(exc).__name__}: {exc}\n"
1875
+ )
1876
+ sys.stderr.flush()
1877
+ return False
961
1878
 
962
1879
 
963
1880
  # Backward compat alias
@@ -972,7 +1889,10 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
972
1889
  ``bird_authenticated`` to False when X clearly returns nothing,
973
1890
  so ``--diagnose`` reflects runtime reality instead of static
974
1891
  credential presence. A transient timeout leaves the status
975
- unchanged (fail open).
1892
+ unchanged (fail open). When False (the safe/diagnose path that
1893
+ doctor uses), NO network is touched: xurl availability comes
1894
+ from local evidence (``xurl_x.has_stored_auth``), never the
1895
+ live ``xurl whoami`` call.
976
1896
 
977
1897
  Returns:
978
1898
  Dict with keys: source, bird_installed, bird_authenticated,
@@ -980,10 +1900,24 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
980
1900
  """
981
1901
  from . import bird_x
982
1902
 
983
- if config.get('AUTH_TOKEN') and config.get('CT0'):
1903
+ # Backends this host may run: the policy's auto chain plus a known pin.
1904
+ # Bird is primed/probed and xquik is probed only when they are in that
1905
+ # set, so an official-only host never touches the scraper or the
1906
+ # third-party API unless the backend is pinned.
1907
+ policy = x_policy(config)
1908
+ pin = x_backend_pin(config)
1909
+ considered = set(policy.auto_chain)
1910
+ if pin in _X_BACKEND_KNOWN:
1911
+ considered.add(pin)
1912
+
1913
+ if 'bird' in considered and config.get('AUTH_TOKEN') and config.get('CT0'):
984
1914
  bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
985
- bird_status = bird_x.get_bird_status()
1915
+ bird_status = dict(bird_x.get_bird_status())
1916
+ if 'bird' not in considered:
1917
+ # Never report the scraper as usable where the policy forbids it.
1918
+ bird_status["authenticated"] = False
986
1919
  xai_available = bool(config.get('XAI_API_KEY'))
1920
+ xapi_available = bool(config.get('X_BEARER_TOKEN'))
987
1921
 
988
1922
  # Report the TRUE auth lane (browser / env / keychain) rather than the static
989
1923
  # "env AUTH_TOKEN" label — tokens usually come from live browser cookies, and
@@ -1004,7 +1938,7 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
1004
1938
  xquik_available = is_xquik_available(config)
1005
1939
  xquik_working: bool | None = None
1006
1940
  xquik_status = ""
1007
- if xquik_available:
1941
+ if xquik_available and 'xquik' in considered:
1008
1942
  if probe:
1009
1943
  from . import xquik
1010
1944
  xquik_working = xquik.probe_works(get_xquik_token(config))
@@ -1012,30 +1946,50 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
1012
1946
  else:
1013
1947
  xquik_status = "configured (not probed)"
1014
1948
 
1015
- # Determine active source. bird (browser cookies) and xAI win when present;
1016
- # when neither is available, xquik is the active X source. A probe that
1017
- # clearly failed (False) means xquik is not actually usable.
1018
- if bird_status["authenticated"]:
1019
- source = 'bird'
1020
- elif xai_available:
1021
- source = 'xai'
1949
+ # Xurl availability, computed ONCE. probe=True (a live diagnose) may run
1950
+ # the real `xurl whoami`; probe=False is the safe path (doctor,
1951
+ # --diagnose, --preflight) and must stay local-only — the live check is
1952
+ # an authenticated X API network call.
1953
+ from . import xurl_x as _xurl_x
1954
+ xurl_available = _xurl_x.is_available() if probe else _xurl_x.has_stored_auth()
1955
+
1956
+ # Grok availability is filesystem-only on both paths (PATH lookup plus the
1957
+ # credential store), so it is safe to compute here regardless of `probe`.
1958
+ # Grok is opt-in only: it appears in grok_available but never wins the
1959
+ # unpinned source selection.
1960
+ from . import grok_x as _grok_x
1961
+ grok_available = _grok_x.has_stored_auth()
1962
+
1963
+ # Determine active source. A pin forces a single backend (R4): ANY known
1964
+ # pin is exclusive, mirroring x_backend_chain's [] semantics. Pinned
1965
+ # backend available -> that source. Pinned backend unavailable -> None.
1966
+ # Otherwise walk the policy's auto chain (default: bird first, cookies
1967
+ # beat XAI_API_KEY when both are present, then xai, xurl, xquik;
1968
+ # official-only: xapi, xai, xurl). Opt-in backends are never
1969
+ # auto-selected; a leftover ~/.grok/auth.json must not steal the X lane.
1970
+ usable = {
1971
+ 'bird': bird_status["authenticated"],
1972
+ 'xai': xai_available,
1973
+ 'xurl': xurl_available,
1974
+ 'xquik': xquik_available and xquik_working is not False,
1975
+ 'grok': grok_available,
1976
+ 'xapi': xapi_available,
1977
+ }
1978
+ if pin in _X_BACKEND_KNOWN:
1979
+ # Pin is exclusive: pinned backend if available, else None (no fallback).
1980
+ source = pin if usable.get(pin) else None
1022
1981
  else:
1023
- from . import xurl_x as _xurl_check
1024
- if _xurl_check.is_available():
1025
- source = 'xurl'
1026
- elif xquik_available and xquik_working is not False:
1027
- source = 'xquik'
1028
- else:
1029
- source = None
1982
+ source = next((b for b in policy.auto_chain if usable.get(b)), None)
1030
1983
 
1031
- from . import xurl_x as _xurl_x
1032
1984
  return {
1033
1985
  "source": source,
1034
1986
  "bird_installed": bird_status["installed"],
1035
1987
  "bird_authenticated": bird_status["authenticated"],
1036
1988
  "bird_username": bird_status["username"],
1037
1989
  "xai_available": xai_available,
1038
- "xurl_available": _xurl_x.is_available(),
1990
+ "xapi_available": xapi_available,
1991
+ "grok_available": grok_available,
1992
+ "xurl_available": xurl_available,
1039
1993
  "xquik_available": xquik_available,
1040
1994
  "xquik_working": xquik_working,
1041
1995
  "xquik_status": xquik_status,