luckiest-co 1.0.47 → 1.0.49

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 (274) 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/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -1,50 +1,82 @@
1
- """X (Twitter) search via xurl CLI — official X API v2 with OAuth2.
1
+ """X (Twitter) search via xurl CLI — official X API v2.
2
2
 
3
- xurl is an open-source CLI for the X API (https://github.com/openclaw/xurl).
4
- It uses OAuth2 with PKCE and automatic token refresh, requiring only a free
3
+ xurl is X's official CLI for the X API
4
+ (https://github.com/xdevplatform/xurl). It requires only a free
5
5
  X Developer App. No xAI subscription or browser cookies needed.
6
6
 
7
- Install: npm install -g xurl
8
- Auth: xurl auth oauth2 login
7
+ Install: npm install -g @xdevplatform/xurl
8
+ Auth: xurl auth app-only <bearer-token> (search / availability)
9
+ xurl auth oauth1 ... (optional; not used for search)
9
10
 
10
11
  Priority: xAI API > Bird/GraphQL > xurl > web-only fallback
11
12
  """
12
13
 
13
14
  import json
14
15
  import re
16
+ import shutil
17
+ import stat
15
18
  import subprocess
16
- from typing import Any, Dict, List, Optional
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
17
22
 
18
- from . import log
19
- from .relevance import token_overlap_relevance as _compute_relevance
23
+ from . import health, http, log, usage
24
+ from . import x_api
25
+ # One X API v2 depth table and one parser: xurl_x re-imports both.
26
+ from .x_api import DEPTH_CONFIG
27
+
28
+ # xurl auth status marks a configured app-only bearer as "bearer: ✓".
29
+ # Search uses --auth app, so availability must require this — oauth1 alone
30
+ # is not enough.
31
+ _BEARER_CONFIGURED_RE = re.compile(r"bearer:\s*✓")
20
32
 
21
33
 
22
34
  def _log(msg: str) -> None:
23
35
  log.source_log("xurl", msg, tty_only=False)
24
36
 
25
37
 
26
- # Depth configurations: number of results to request
27
- DEPTH_CONFIG = {
28
- "quick": 10,
29
- "default": 30,
30
- "deep": 60,
31
- }
38
+
39
+ # Memoized availability, mirroring health.py's per-process dependency-probe
40
+ # cache: each uncached is_available() check spawns an `xurl auth status`
41
+ # subprocess (local credential status; no network). The doctor/safe-diagnose
42
+ # path never uses it — see stored_auth_status()/has_stored_auth() below —
43
+ # but research-time callers may consult it more than once per process.
44
+ # None means "not yet probed".
45
+ _availability_cache: Optional[bool] = None
46
+
47
+
48
+ def clear_availability_cache() -> None:
49
+ """Reset the memoized is_available() result (tests, or a re-check after auth)."""
50
+ global _availability_cache
51
+ _availability_cache = None
32
52
 
33
53
 
34
54
  def is_available() -> bool:
35
- """Check if xurl is installed and has valid authentication.
55
+ """Check if xurl is installed and has app-only bearer auth.
36
56
 
37
- Returns True only if xurl binary is found AND the user is authenticated
38
- (i.e. ``xurl whoami`` exits 0 and returns a username field).
57
+ Returns True only if xurl binary is found AND ``xurl auth status``
58
+ exits 0 with a configured app-only bearer (``bearer: ✓``). OAuth1
59
+ alone is insufficient — ``search_x`` pins ``--auth app``.
60
+ Memoized per process; ``clear_availability_cache()`` resets.
39
61
  """
62
+ global _availability_cache
63
+ if _availability_cache is None:
64
+ _availability_cache = _is_available_uncached()
65
+ return _availability_cache
66
+
67
+
68
+ def _is_available_uncached() -> bool:
40
69
  try:
41
70
  result = subprocess.run(
42
- ["xurl", "whoami"],
71
+ ["xurl", "auth", "status"],
43
72
  capture_output=True,
44
73
  text=True,
45
74
  timeout=10,
46
75
  )
47
- return result.returncode == 0 and '"username"' in result.stdout
76
+ return (
77
+ result.returncode == 0
78
+ and _BEARER_CONFIGURED_RE.search(result.stdout) is not None
79
+ )
48
80
  except (OSError, subprocess.TimeoutExpired):
49
81
  # OSError covers FileNotFoundError (no xurl on PATH) and
50
82
  # PermissionError (a non-executable match on PATH, e.g. WSL's
@@ -52,56 +84,272 @@ def is_available() -> bool:
52
84
  return False
53
85
 
54
86
 
87
+ # ---------------------------------------------------------------------------
88
+ # Local auth evidence (doctor / safe-diagnose path — no subprocess, no
89
+ # network).
90
+ #
91
+ # xurl persists OAuth credentials to an on-disk token store at ~/.xurl
92
+ # (YAML in current releases; legacy versions wrote JSON — see the upstream
93
+ # store package at github.com/xdevplatform/xurl). A populated store is the
94
+ # strongest LOCAL evidence of authentication obtainable without spending a
95
+ # network call, so doctor keys on it and reports "auth not live-verified"
96
+ # instead of running `xurl whoami` (a real, authenticated X API request
97
+ # that would violate doctor's no-network guarantee).
98
+ # ---------------------------------------------------------------------------
99
+
100
+ AUTH_OK = "ok" # token store present with stored credentials
101
+ AUTH_MISSING = "missing" # no token store, or no credentials stored in it
102
+ AUTH_ERROR = "error" # token store exists but could not be read
103
+
104
+ # Substrings a populated store carries in both the YAML and legacy JSON
105
+ # formats (per-user oauth2 token blocks, or an app-only bearer token).
106
+ _TOKEN_STORE_MARKERS = (
107
+ "access_token",
108
+ "bearer_token",
109
+ "oauth2_tokens",
110
+ "oauth1_tokens",
111
+ )
112
+
113
+
114
+ def _is_file(path: Path) -> bool:
115
+ """True when *path* is a regular file; raise on an unreadable stat.
116
+
117
+ ``pathlib.Path.is_file()`` swallows ``OSError`` into ``False``, which
118
+ would silently misreport a permission-denied (EACCES) store as absent.
119
+ Probing via ``stat`` keeps the typed ``AUTH_ERROR`` path reachable for
120
+ real stat failures. A missing path (``FileNotFoundError``) returns False —
121
+ absence is a normal state (this also covers a dangling symlink whose
122
+ target is gone). Path stubs without ``stat``/``is_file`` support (test
123
+ doubles) fall back to their own behavior.
124
+ """
125
+ try:
126
+ return stat.S_ISREG(path.stat().st_mode)
127
+ except FileNotFoundError:
128
+ return False
129
+ except (AttributeError, TypeError):
130
+ try:
131
+ return bool(path.is_file())
132
+ except AttributeError:
133
+ return False
134
+
135
+
136
+ def _is_dir(path: Path) -> bool:
137
+ """True when *path* is a directory; raise on an unreadable stat.
138
+
139
+ Same rationale as :func:`_is_file` — pathlib's ``is_dir()`` hides
140
+ ``OSError``, so a permission-denied parent would read as a missing store.
141
+ """
142
+ try:
143
+ return stat.S_ISDIR(path.stat().st_mode)
144
+ except FileNotFoundError:
145
+ return False
146
+ except (AttributeError, TypeError):
147
+ try:
148
+ return bool(path.is_dir())
149
+ except AttributeError:
150
+ return False
151
+
152
+
153
+ def token_store_path() -> Path:
154
+ """xurl's on-disk OAuth token store (~/.xurl/auth.yml).
155
+
156
+ Current xurl (>=1.1) stores credentials in a YAML file at
157
+ ``~/.xurl/auth.yml``; ``~/.xurl`` itself is a directory. Legacy
158
+ releases wrote a flat file at ``~/.xurl``. ``stored_auth_status()``
159
+ resolves both layouts; this returns the canonical current path.
160
+ """
161
+ return Path.home() / ".xurl" / "auth.yml"
162
+
163
+
164
+ def stored_auth_status() -> Tuple[str, str]:
165
+ """Local-only evidence of xurl authentication: ``(status, detail)``.
166
+
167
+ Reads only the on-disk token store — never spawns xurl, never touches
168
+ the network. ``status`` is AUTH_OK (store holds credentials),
169
+ AUTH_MISSING (no store / empty store / no credential markers), or
170
+ AUTH_ERROR (store exists but cannot be read — surfaced as a typed
171
+ error, not as "unconfigured").
172
+ """
173
+ # Resolve the actual credential file across layouts. Current xurl keeps
174
+ # ~/.xurl/auth.yml inside the ~/.xurl directory; legacy installs wrote a
175
+ # flat ~/.xurl file. Honor whatever token_store_path() resolves to and
176
+ # check both shapes so a valid directory layout is never misread as a
177
+ # missing token store. Path stubs that cannot combine (e.g. an unreadable
178
+ # store stub without / support) still reach the typed error path below.
179
+ path = token_store_path()
180
+ try:
181
+ base = path.parent if path.name == "auth.yml" else path
182
+ except (AttributeError, TypeError):
183
+ # A path stub with no parent/name support: treat it as the file itself.
184
+ base = path
185
+ try:
186
+ # Use stat rather than is_file()/is_dir(): pathlib swallows OSError
187
+ # into False, which would silently turn a permission-denied store into
188
+ # "no token store" instead of the typed AUTH_ERROR.
189
+ candidates = [base / "auth.yml", base] if _is_dir(base) else [base, base / "auth.yml"]
190
+ except (TypeError, AttributeError):
191
+ # A path stub that cannot combine (no __truediv__): the path itself is
192
+ # the only candidate.
193
+ candidates = [path]
194
+ except OSError as exc:
195
+ return (
196
+ AUTH_ERROR,
197
+ f"token store {base} unreadable: {type(exc).__name__}: {exc}",
198
+ )
199
+ try:
200
+ path = next(c for c in candidates if _is_file(c))
201
+ except StopIteration:
202
+ return AUTH_MISSING, f"no token store at {token_store_path()}"
203
+ except OSError as exc:
204
+ return (
205
+ AUTH_ERROR,
206
+ f"token store {base} unreadable: {type(exc).__name__}: {exc}",
207
+ )
208
+ try:
209
+ content = path.read_text(encoding="utf-8", errors="replace")
210
+ except OSError as exc:
211
+ return (
212
+ AUTH_ERROR,
213
+ f"token store {path} unreadable: {type(exc).__name__}: {exc}",
214
+ )
215
+ if any(marker in content for marker in _TOKEN_STORE_MARKERS):
216
+ return AUTH_OK, f"stored OAuth credentials found in {path}"
217
+ return AUTH_MISSING, f"token store {path} has no stored credentials"
218
+
219
+
220
+ def has_stored_auth() -> bool:
221
+ """Local-only availability: xurl on PATH with stored credentials.
222
+
223
+ The doctor/safe-diagnose counterpart of ``is_available()`` — the same
224
+ "installed and authenticated" question answered from local evidence
225
+ only (PATH lookup + token store), never a live ``xurl whoami``. A
226
+ broken token store reads as unavailable here; the doctor probe layer
227
+ (``backends._probe_xurl``) reports that case as a typed error.
228
+ """
229
+ return shutil.which("xurl") is not None and stored_auth_status()[0] == AUTH_OK
230
+
231
+
232
+ # Engine-authored fixed error strings (the same rule as x_api): xurl's
233
+ # stderr can echo the request, the bearer, or an account id, so it never
234
+ # reaches the outcome detail. Each string carries a marker that
235
+ # http.classify_failure recognizes.
236
+ ERR_PAYMENT_REQUIRED = "xurl: payment required (X API credits exhausted)"
237
+ ERR_UNAUTHORIZED = "xurl: unauthorized (bearer token rejected)"
238
+ ERR_FORBIDDEN = "xurl: forbidden (bearer token lacks access)"
239
+ ERR_RATE_LIMITED = "xurl: rate limit exceeded (X API)"
240
+ ERR_FAILED = "xurl: search failed"
241
+ # HTTP 400 from X's query grammar (for example a bare lowercase "and").
242
+ ERR_INVALID_REQUEST = "xurl: X rejected the query (invalid request)"
243
+ ERR_EMPTY_QUERY = "xurl: no searchable keywords left after removing X search operators"
244
+ ERR_INVALID_JSON = "xurl: invalid JSON from xurl"
245
+ ERR_NOT_FOUND = "xurl not found in PATH"
246
+ ERR_TIMED_OUT = "xurl search timed out (30s)"
247
+
248
+
249
+ def _classify_cli_failure(output: str) -> str:
250
+ """Map xurl's stderr/stdout to a fixed string via the shared classifier."""
251
+ text = output or ""
252
+ state = http.classify_failure(message=text)
253
+ if state == health.PAYMENT_REQUIRED:
254
+ return ERR_PAYMENT_REQUIRED
255
+ if state == health.AUTH_FAILED:
256
+ # The shared vocabulary has one auth state; split the wording only.
257
+ lowered = text.lower()
258
+ if "401" in lowered or "unauthorized" in lowered:
259
+ return ERR_UNAUTHORIZED
260
+ return ERR_FORBIDDEN
261
+ if state == health.RATE_LIMITED:
262
+ return ERR_RATE_LIMITED
263
+ # X's v2 problem type (".../2/problems/invalid-request") or its title
264
+ # ("Invalid Request"). Only the fixed string leaves; the body may echo
265
+ # the query or request headers.
266
+ lowered = text.lower()
267
+ if "invalid-request" in lowered or "invalid request" in lowered:
268
+ return ERR_INVALID_REQUEST
269
+ return ERR_FAILED
270
+
271
+
55
272
  def search_x(
56
273
  query: str,
57
274
  depth: str = "default",
275
+ deadline: Optional[float] = None,
58
276
  ) -> Dict[str, Any]:
59
277
  """Search X via xurl CLI using X API v2 search/recent.
60
278
 
279
+ The topic is sanitized into space-joined keywords first
280
+ (``x_api.build_keyword_query``): X's v2 grammar rejects a bare lowercase
281
+ ``and``/``or`` with HTTP 400 and treats ``from:``-style tokens, leading
282
+ ``-``, parentheses and quotes as operators, and the pipeline always
283
+ passes the raw user topic here.
284
+
61
285
  Args:
62
- query: Search query string
286
+ query: Search topic (raw user text is fine; it is sanitized)
63
287
  depth: "quick", "default", or "deep"
288
+ deadline: Optional shared wall-clock deadline (``time.monotonic()``
289
+ instant) from the X backend chain. The single subprocess timeout
290
+ is clamped to the time left; a call past the deadline never starts.
64
291
 
65
292
  Returns:
66
293
  Raw JSON response from X API v2 tweets/search/recent, or a dict
67
- with an "error" key on failure.
294
+ with an "error" key holding a fixed string on failure. The CLI's
295
+ own output reaches only the debug log, never the error.
68
296
  """
297
+ search_query = x_api.build_keyword_query(query)
298
+ if not search_query:
299
+ return {"error": ERR_EMPTY_QUERY}
300
+
69
301
  max_results = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
70
302
  # X API v2 search/recent requires max_results in 10–100 range
71
303
  max_results = max(10, min(100, max_results))
72
-
304
+ timeout = 30
305
+ if deadline is not None:
306
+ remaining = deadline - time.monotonic()
307
+ if remaining < 1:
308
+ return {"error": "xurl: chain budget exhausted"}
309
+ timeout = max(1, min(timeout, int(remaining)))
310
+
311
+ charge = None
73
312
  try:
313
+ # --auth app (app-only bearer): xurl >=1.1 mis-signs OAuth1 requests
314
+ # whose query needs percent-encoding (spaces, parens, ...) -> 401.
315
+ # Bearer auth sends no signature, so multi-word queries work.
316
+ charge = usage.begin("x")
74
317
  result = subprocess.run(
75
- ["xurl", "search", query, "-n", str(max_results)],
318
+ ["xurl", "search", search_query, "-n", str(max_results), "--auth", "app"],
76
319
  capture_output=True,
77
320
  text=True,
78
- timeout=30,
321
+ timeout=timeout,
79
322
  )
80
323
 
81
324
  if result.returncode != 0:
82
325
  error_text = result.stderr.strip() or result.stdout.strip()
83
- return {"error": f"xurl search failed: {error_text}"}
326
+ classified = _classify_cli_failure(error_text)
327
+ # The raw CLI output can echo the bearer; only the fixed string
328
+ # and its size reach the debug log.
329
+ log.debug(f"xurl exit {result.returncode}: {classified} ({len(error_text)} chars)")
330
+ return {"error": classified}
84
331
 
85
332
  return json.loads(result.stdout)
86
333
 
87
334
  except FileNotFoundError:
88
- return {"error": "xurl not found in PATH"}
335
+ usage.cancel(charge)
336
+ return {"error": ERR_NOT_FOUND}
89
337
  except subprocess.TimeoutExpired:
90
- return {"error": "xurl search timed out (30s)"}
91
- except json.JSONDecodeError as exc:
92
- return {"error": f"Invalid JSON from xurl: {exc}"}
338
+ return {"error": ERR_TIMED_OUT}
339
+ except json.JSONDecodeError:
340
+ return {"error": ERR_INVALID_JSON}
93
341
  except Exception as exc:
94
- return {"error": f"{type(exc).__name__}: {exc}"}
342
+ return {"error": f"xurl: {type(exc).__name__}"}
95
343
 
96
344
 
97
345
  def parse_x_response(
98
346
  response: Dict[str, Any],
99
347
  topic: str = "",
100
348
  ) -> List[Dict[str, Any]]:
101
- """Parse xurl search response into normalized item dicts.
349
+ """Parse an xurl search response into normalized item dicts.
102
350
 
103
- Output format matches the existing XItem schema used by xai_x and bird_x:
104
- id, text, url, author_handle, date, engagement, why_relevant, relevance.
351
+ Delegates to the shared X API v2 parser (``x_api.parse_v2_response``)
352
+ so xurl and xapi never drift; only the id prefix differs.
105
353
 
106
354
  Args:
107
355
  response: Raw X API v2 response dict from search_x()
@@ -110,62 +358,7 @@ def parse_x_response(
110
358
  Returns:
111
359
  List of item dicts. Empty list on error or no results.
112
360
  """
113
- items: List[Dict[str, Any]] = []
114
-
115
- if "error" in response:
361
+ if isinstance(response, dict) and "error" in response:
116
362
  _log(f"Error in response: {response['error']}")
117
- return items
118
-
119
- data = response.get("data") or []
120
- if not data:
121
- return items
122
-
123
- # Build author lookup from includes.users
124
- authors: Dict[str, Dict[str, Any]] = {}
125
- for user in (response.get("includes") or {}).get("users") or []:
126
- authors[user["id"]] = user
127
-
128
- for i, tweet in enumerate(data):
129
- author_id = tweet.get("author_id", "")
130
- author = authors.get(author_id, {})
131
- username = author.get("username", "")
132
-
133
- tweet_id = tweet.get("id", "")
134
- url = f"https://x.com/{username}/status/{tweet_id}" if username else ""
135
-
136
- # Parse public_metrics
137
- engagement: Optional[Dict[str, Any]] = None
138
- metrics = tweet.get("public_metrics") or {}
139
- if metrics:
140
- engagement = {
141
- "likes": metrics.get("like_count", 0),
142
- "reposts": metrics.get("retweet_count", 0),
143
- "replies": metrics.get("reply_count", 0),
144
- "quotes": metrics.get("quote_count", 0),
145
- }
146
-
147
- # Parse ISO 8601 date → YYYY-MM-DD
148
- date: Optional[str] = None
149
- created = tweet.get("created_at", "")
150
- if created:
151
- m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
152
- if m:
153
- date = m.group(1)
154
-
155
- text = tweet.get("text", "").strip()
156
-
157
- # Relevance score via shared token-overlap function
158
- relevance = _compute_relevance(topic, text) if topic else 0.5
159
-
160
- items.append({
161
- "id": f"XURL{i + 1}",
162
- "text": text[:500],
163
- "url": url,
164
- "author_handle": username,
165
- "date": date,
166
- "engagement": engagement,
167
- "why_relevant": "",
168
- "relevance": relevance,
169
- })
170
-
171
- return items
363
+ return []
364
+ return x_api.parse_v2_response(response, topic, None, id_prefix="XURL")