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
@@ -19,9 +19,28 @@ import tempfile
19
19
  from pathlib import Path
20
20
  from typing import Dict, List, Optional
21
21
 
22
+ from .cookie_paths import path_is_dir, path_is_file
23
+
22
24
  logger = logging.getLogger(__name__)
23
25
 
24
26
 
27
+ def has_complete_pair(result: Optional[Dict[str, str]], cookie_names) -> bool:
28
+ """Return True when ``result`` holds every requested cookie with a value.
29
+
30
+ Shared by the Firefox profile scan, ``env.extract_browser_credentials``,
31
+ and the setup wizard so a partial pair (e.g. a lone ``ct0`` from a
32
+ logged-out session) never shadows a complete one. Empty values do not
33
+ count: ``{"auth_token": "", "ct0": ""}`` is incomplete. ``cookie_names``
34
+ is the spec's cookie list (a spec dict with a ``"cookies"`` key also
35
+ works).
36
+ """
37
+ if not result:
38
+ return False
39
+ if isinstance(cookie_names, dict):
40
+ cookie_names = cookie_names.get("cookies", [])
41
+ return all(result.get(name) for name in cookie_names)
42
+
43
+
25
44
  def _lock_temp_cookie_copy(path: str) -> None:
26
45
  """Restrict copied cookie DB temp files to the current user on POSIX."""
27
46
  if os.name == "nt":
@@ -48,18 +67,30 @@ def _get_wsl_firefox_profiles_dir() -> Optional[Path]:
48
67
  directories (skips Public, Default, etc.).
49
68
  """
50
69
  mnt_users = Path("/mnt/c/Users")
51
- if not mnt_users.is_dir():
70
+ if not path_is_dir(mnt_users):
52
71
  return None
53
72
  skip = {"Public", "Default", "Default User", "All Users"}
73
+ denied = None
54
74
  try:
55
75
  for user_dir in sorted(mnt_users.iterdir()):
56
- if user_dir.name in skip or not user_dir.is_dir():
76
+ if user_dir.name in skip:
57
77
  continue
58
- ff_dir = user_dir / "AppData" / "Roaming" / "Mozilla" / "Firefox"
59
- if ff_dir.is_dir():
60
- return ff_dir
78
+ try:
79
+ if not path_is_dir(user_dir):
80
+ continue
81
+ ff_dir = user_dir / "AppData" / "Roaming" / "Mozilla" / "Firefox"
82
+ if path_is_dir(ff_dir):
83
+ return ff_dir
84
+ except PermissionError as exc:
85
+ if denied is None:
86
+ denied = exc
87
+ except PermissionError as exc:
88
+ if denied is None:
89
+ denied = exc
61
90
  except OSError:
62
91
  pass
92
+ if denied is not None:
93
+ raise denied
63
94
  return None
64
95
 
65
96
 
@@ -71,8 +102,12 @@ def _get_firefox_profiles_dir() -> Optional[Path]:
71
102
  elif system == "Linux":
72
103
  # Default location for most distros
73
104
  path = Path.home() / ".mozilla" / "firefox"
74
- if path.is_dir():
75
- return path
105
+ denied = None
106
+ try:
107
+ if path_is_dir(path):
108
+ return path
109
+ except PermissionError as exc:
110
+ denied = exc
76
111
  # Some distros (e.g. Fedora) honour $XDG_CONFIG_HOME
77
112
  xdg_config = os.environ.get("XDG_CONFIG_HOME")
78
113
  if xdg_config and os.path.isabs(xdg_config):
@@ -83,46 +118,95 @@ def _get_firefox_profiles_dir() -> Optional[Path]:
83
118
  # Windows: %APPDATA%\Mozilla\Firefox — best-effort
84
119
  appdata = Path.home() / "AppData" / "Roaming" / "Mozilla" / "Firefox"
85
120
  path = appdata
86
- return path if path.is_dir() else None
121
+ if path_is_dir(path):
122
+ return path
123
+ if system == "Linux" and denied is not None:
124
+ raise denied
125
+ return None
87
126
 
88
127
 
89
- def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
128
+ def _load_profiles_ini(ini_path: Path) -> configparser.ConfigParser:
129
+ """Parse Firefox profiles.ini, retrying UTF-16 LE used on Windows (#1067)."""
130
+ config = configparser.ConfigParser()
131
+ try:
132
+ config.read(str(ini_path), encoding="utf-8")
133
+ except UnicodeDecodeError:
134
+ config.read(str(ini_path), encoding="utf-16")
135
+ return config
136
+
137
+
138
+ def _find_default_profile(
139
+ profiles_dir: Path, denials: Optional[list[PermissionError]] = None
140
+ ) -> Optional[Path]:
90
141
  """Parse profiles.ini to find the default profile directory.
91
142
 
92
143
  Looks for a section with Default=1. Falls back to the first profile
93
144
  directory found on disk if profiles.ini is missing or malformed.
94
145
  """
95
146
  ini_path = profiles_dir / "profiles.ini"
147
+ denied: list[PermissionError] = []
96
148
 
97
- if ini_path.is_file():
149
+ try:
150
+ has_ini = path_is_file(ini_path)
151
+ except PermissionError as exc:
152
+ denied.append(exc)
153
+ has_ini = False
154
+ if has_ini:
98
155
  try:
99
- config = configparser.ConfigParser()
100
- config.read(str(ini_path), encoding="utf-8")
156
+ config = _load_profiles_ini(ini_path)
101
157
 
102
158
  # First pass: Install* section (Firefox >= 67 format, takes priority)
103
159
  for section in config.sections():
104
160
  if section.startswith("Install") and config.has_option(section, "Default"):
105
161
  raw = config.get(section, "Default")
106
162
  candidate = profiles_dir / raw
107
- if candidate.is_dir():
163
+ try:
164
+ found = path_is_dir(candidate)
165
+ except PermissionError as exc:
166
+ denied.append(exc)
167
+ continue
168
+ if found:
169
+ if denials is not None:
170
+ denials.extend(denied)
108
171
  return candidate
109
172
 
110
173
  # Second pass: Profile section with Default=1
111
174
  for section in config.sections():
112
175
  if section.startswith("Profile") and config.has_option(section, "Default") and config.get(section, "Default") == "1":
113
- return _resolve_profile_path(profiles_dir, config, section)
176
+ try:
177
+ resolved = _resolve_profile_path(profiles_dir, config, section)
178
+ except PermissionError as exc:
179
+ denied.append(exc)
180
+ continue
181
+ if resolved is not None:
182
+ if denials is not None:
183
+ denials.extend(denied)
184
+ return resolved
114
185
 
115
186
  # Third pass: first Profile section that exists on disk
116
187
  for section in config.sections():
117
188
  if section.startswith("Profile"):
118
- resolved = _resolve_profile_path(profiles_dir, config, section)
119
- if resolved and resolved.is_dir():
189
+ try:
190
+ resolved = _resolve_profile_path(profiles_dir, config, section)
191
+ except PermissionError as exc:
192
+ denied.append(exc)
193
+ continue
194
+ if resolved:
195
+ if denials is not None:
196
+ denials.extend(denied)
120
197
  return resolved
121
- except (configparser.Error, OSError) as exc:
198
+ except PermissionError as exc:
199
+ denied.append(exc)
200
+ except (configparser.Error, OSError, UnicodeDecodeError) as exc:
122
201
  logger.debug("Failed to parse profiles.ini: %s", exc)
123
202
 
124
203
  # Fallback: scan directory for anything that looks like a profile
125
- return _fallback_find_profile(profiles_dir)
204
+ profile = _fallback_find_profile(profiles_dir, denied)
205
+ if denials is not None:
206
+ denials.extend(denied)
207
+ elif denied and profile is None:
208
+ raise denied[0]
209
+ return profile
126
210
 
127
211
 
128
212
  def _resolve_profile_path(
@@ -137,17 +221,31 @@ def _resolve_profile_path(
137
221
  candidate = profiles_dir / raw_path
138
222
  else:
139
223
  candidate = Path(raw_path)
140
- return candidate if candidate.is_dir() else None
224
+ return candidate if path_is_dir(candidate) else None
141
225
 
142
226
 
143
- def _fallback_find_profile(profiles_dir: Path) -> Optional[Path]:
227
+ def _fallback_find_profile(
228
+ profiles_dir: Path, denials: Optional[list[PermissionError]] = None
229
+ ) -> Optional[Path]:
144
230
  """Find the first directory that contains cookies.sqlite."""
231
+ denied: list[PermissionError] = []
145
232
  try:
146
233
  for child in sorted(profiles_dir.iterdir()):
147
- if child.is_dir() and (child / "cookies.sqlite").is_file():
148
- return child
234
+ try:
235
+ if path_is_dir(child) and path_is_file(child / "cookies.sqlite"):
236
+ if denials is not None:
237
+ denials.extend(denied)
238
+ return child
239
+ except PermissionError as exc:
240
+ denied.append(exc)
241
+ except PermissionError as exc:
242
+ denied.append(exc)
149
243
  except OSError:
150
244
  pass
245
+ if denials is not None:
246
+ denials.extend(denied)
247
+ elif denied:
248
+ raise denied[0]
151
249
  return None
152
250
 
153
251
 
@@ -159,7 +257,7 @@ def _query_cookies_db(
159
257
  Firefox locks cookies.sqlite while running, so we copy first.
160
258
  Returns {name: value} dict or None if no matching cookies found.
161
259
  """
162
- if not db_path.is_file():
260
+ if not path_is_file(db_path):
163
261
  return None
164
262
 
165
263
  tmp_fd = None
@@ -196,6 +294,11 @@ def _query_cookies_db(
196
294
  return None
197
295
  return {name: value for name, value in rows}
198
296
 
297
+ except PermissionError as exc:
298
+ if exc.filename == str(db_path):
299
+ raise
300
+ logger.debug("Failed to query cookies database %s: %s", db_path, exc)
301
+ return None
199
302
  except (sqlite3.Error, OSError) as exc:
200
303
  logger.debug("Failed to query cookies database %s: %s", db_path, exc)
201
304
  return None
@@ -219,29 +322,51 @@ def _try_firefox_dir(profiles_dir: Path, domain: str, cookie_names: List[str]) -
219
322
  Tries the default profile first, then falls back to scanning all
220
323
  profiles for matching cookies. This handles multi-profile setups
221
324
  where the user is logged into x.com on a non-default profile.
325
+ A complete match (all ``cookie_names``) always wins: a partial
326
+ default-profile result is kept only as a fallback.
222
327
  """
223
- default_profile = _find_default_profile(profiles_dir)
328
+ denials: list[PermissionError] = []
329
+ default_profile = _find_default_profile(profiles_dir, denials)
224
330
  profiles_tried = 0
331
+ fallback: Optional[Dict[str, str]] = None
225
332
  if default_profile is not None:
226
- result = _query_cookies_db(default_profile / "cookies.sqlite", domain, cookie_names)
333
+ try:
334
+ result = _query_cookies_db(default_profile / "cookies.sqlite", domain, cookie_names)
335
+ except PermissionError as exc:
336
+ denials.append(exc)
337
+ result = None
227
338
  if result is not None:
228
- return result
339
+ if has_complete_pair(result, cookie_names):
340
+ return result
341
+ fallback = result
229
342
  profiles_tried = 1
230
343
  # Fallback: scan every profile directory for matching cookies
231
344
  try:
232
345
  for child in sorted(profiles_dir.iterdir()):
233
- if not child.is_dir():
234
- continue
235
- if default_profile is not None and child == default_profile:
236
- continue
237
- db = child / "cookies.sqlite"
238
- if db.is_file():
239
- result = _query_cookies_db(db, domain, cookie_names)
240
- if result is not None:
241
- return result
242
- profiles_tried += 1
346
+ try:
347
+ if not path_is_dir(child):
348
+ continue
349
+ if default_profile is not None and child == default_profile:
350
+ continue
351
+ db = child / "cookies.sqlite"
352
+ if path_is_file(db):
353
+ result = _query_cookies_db(db, domain, cookie_names)
354
+ if result is not None:
355
+ if has_complete_pair(result, cookie_names):
356
+ return result
357
+ if fallback is None:
358
+ fallback = result
359
+ profiles_tried += 1
360
+ except PermissionError as exc:
361
+ denials.append(exc)
362
+ except PermissionError as exc:
363
+ denials.append(exc)
243
364
  except OSError:
244
365
  pass
366
+ if denials:
367
+ raise denials[0]
368
+ if fallback is not None:
369
+ return fallback
245
370
  logger.debug("No matching cookies found in %d Firefox profile(s)", profiles_tried)
246
371
  return None
247
372
 
@@ -265,18 +390,41 @@ def extract_firefox_cookies(
265
390
  Returns:
266
391
  Dict of {cookie_name: cookie_value} or None if extraction fails.
267
392
  """
268
- profiles_dir = _get_firefox_profiles_dir()
393
+ denied = None
394
+ try:
395
+ profiles_dir = _get_firefox_profiles_dir()
396
+ except PermissionError as exc:
397
+ denied = exc
398
+ profiles_dir = None
269
399
  if profiles_dir is not None:
270
- result = _try_firefox_dir(profiles_dir, domain, cookie_names)
400
+ try:
401
+ result = _try_firefox_dir(profiles_dir, domain, cookie_names)
402
+ except PermissionError as exc:
403
+ denied = exc
404
+ result = None
271
405
  if result is not None:
272
406
  return result
273
407
 
274
408
  if platform.system() == "Linux" and _is_wsl():
275
- wsl_dir = _get_wsl_firefox_profiles_dir()
409
+ try:
410
+ wsl_dir = _get_wsl_firefox_profiles_dir()
411
+ except PermissionError as exc:
412
+ if denied is None:
413
+ denied = exc
414
+ wsl_dir = None
276
415
  if wsl_dir is not None:
277
416
  logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
278
- return _try_firefox_dir(wsl_dir, domain, cookie_names)
417
+ try:
418
+ result = _try_firefox_dir(wsl_dir, domain, cookie_names)
419
+ except PermissionError as exc:
420
+ if denied is None:
421
+ denied = exc
422
+ result = None
423
+ if result is not None:
424
+ return result
279
425
 
426
+ if denied is not None:
427
+ raise denied
280
428
  if profiles_dir is None:
281
429
  logger.debug("Firefox profiles directory not found")
282
430
  return None
@@ -299,6 +447,8 @@ def extract_chrome_cookies(
299
447
  try:
300
448
  from .chrome_cookies import extract_chrome_cookies_macos
301
449
  return extract_chrome_cookies_macos(domain, cookie_names)
450
+ except PermissionError:
451
+ raise
302
452
  except Exception as exc:
303
453
  logger.debug("Chrome cookie extraction failed: %s", exc)
304
454
  return None
@@ -322,6 +472,8 @@ def extract_brave_cookies(
322
472
  try:
323
473
  from .chrome_cookies import extract_brave_cookies_macos
324
474
  return extract_brave_cookies_macos(domain, cookie_names)
475
+ except PermissionError:
476
+ raise
325
477
  except Exception as exc:
326
478
  logger.debug("Brave cookie extraction failed: %s", exc)
327
479
  return None
@@ -342,6 +494,8 @@ def _extract_chromium_family_cookies(
342
494
  try:
343
495
  from .chrome_cookies import extract_chromium_browser_cookies_macos
344
496
  return extract_chromium_browser_cookies_macos(browser, domain, cookie_names)
497
+ except PermissionError:
498
+ raise
345
499
  except Exception as exc:
346
500
  logger.debug("%s cookie extraction failed: %s", browser, exc)
347
501
  return None
@@ -388,6 +542,8 @@ def extract_safari_cookies(
388
542
  try:
389
543
  from .safari_cookies import extract_safari_cookies_macos
390
544
  return extract_safari_cookies_macos(domain, cookie_names)
545
+ except PermissionError:
546
+ raise
391
547
  except Exception as exc:
392
548
  logger.debug("Safari cookie extraction failed: %s", exc)
393
549
  return None
@@ -425,20 +581,41 @@ def _extract_firefox_with_source(
425
581
  Returns (cookies, "firefox") for native Linux/macOS Firefox, or
426
582
  (cookies, "firefox-wsl") for Windows Firefox accessed via WSL2.
427
583
  """
428
- profiles_dir = _get_firefox_profiles_dir()
584
+ denied = None
585
+ try:
586
+ profiles_dir = _get_firefox_profiles_dir()
587
+ except PermissionError as exc:
588
+ denied = exc
589
+ profiles_dir = None
429
590
  if profiles_dir is not None:
430
- result = _try_firefox_dir(profiles_dir, domain, cookie_names)
591
+ try:
592
+ result = _try_firefox_dir(profiles_dir, domain, cookie_names)
593
+ except PermissionError as exc:
594
+ denied = exc
595
+ result = None
431
596
  if result is not None:
432
597
  return (result, "firefox")
433
598
 
434
599
  if platform.system() == "Linux" and _is_wsl():
435
- wsl_dir = _get_wsl_firefox_profiles_dir()
600
+ try:
601
+ wsl_dir = _get_wsl_firefox_profiles_dir()
602
+ except PermissionError as exc:
603
+ if denied is None:
604
+ denied = exc
605
+ wsl_dir = None
436
606
  if wsl_dir is not None:
437
607
  logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir)
438
- result = _try_firefox_dir(wsl_dir, domain, cookie_names)
608
+ try:
609
+ result = _try_firefox_dir(wsl_dir, domain, cookie_names)
610
+ except PermissionError as exc:
611
+ if denied is None:
612
+ denied = exc
613
+ result = None
439
614
  if result is not None:
440
615
  return (result, "firefox-wsl")
441
616
 
617
+ if denied is not None:
618
+ raise denied
442
619
  return None
443
620
 
444
621
 
@@ -0,0 +1,27 @@
1
+ """Path probes that preserve browser-data permission failures."""
2
+
3
+ import stat
4
+ from pathlib import Path
5
+
6
+
7
+ def _mode(path: Path) -> int | None:
8
+ try:
9
+ return path.stat().st_mode
10
+ except PermissionError:
11
+ raise
12
+ except OSError:
13
+ return None
14
+
15
+
16
+ def path_exists(path: Path) -> bool:
17
+ return _mode(path) is not None
18
+
19
+
20
+ def path_is_file(path: Path) -> bool:
21
+ mode = _mode(path)
22
+ return mode is not None and stat.S_ISREG(mode)
23
+
24
+
25
+ def path_is_dir(path: Path) -> bool:
26
+ mode = _mode(path)
27
+ return mode is not None and stat.S_ISDIR(mode)