返回 last30days-skill
setup_wizard.py
根目录 / skills / last30days / scripts / lib / setup_wizard.py
1 """First-run setup wizard for last30days.
2
3 Detects first run, performs auto-setup (cookie extraction + yt-dlp check),
4 and writes configuration. The actual wizard UI is SKILL.md-driven (the LLM
5 presents it), but this module provides the detection and setup actions.
6 """
7
8 import json
9 import logging
10 import os
11 import re
12 import shutil
13 import subprocess
14 import time
15 from pathlib import Path
16 from typing import Any, Dict, Optional, Tuple
17 from urllib.error import HTTPError, URLError
18 from urllib.request import Request, urlopen
19
20 from . import brightdata
21
22 logger = logging.getLogger(__name__)
23
24
25 def is_first_run(config: Dict[str, Any]) -> bool:
26 """Return True if the setup wizard has not been completed.
27
28 Checks for SETUP_COMPLETE in the config dict. If it's not set
29 (None or empty string), the user hasn't gone through setup yet.
30 """
31 return not config.get("SETUP_COMPLETE")
32
33
34 _WELCOME_TEXT = """Welcome to /last30days! I research any topic across Reddit, X, YouTube, TikTok, Digg, arXiv, Techmeme, HN, Polymarket & more - what people actually said in the last 30 days. Let's get you set up (~30s).
35
36 I synthesize what people are actually saying right now across social, news, and market sources.
37
38 Auto setup gives you the core sources free in about 30 seconds:
39 - Reddit with comments - free keyless discovery (RSS + shreddit), no API key needed.
40 - YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars).
41 - Digg - trending news, GitHub stars, and pipeline feeds - installs the free, keyless Digg CLI.
42 - arXiv (papers) + Techmeme (tech-news) - install free, keyless Printing Press CLIs and run on any topic (arXiv is relevance + recency gated to research topics).
43 - StockTwits - retail trader sentiment - auto-on when your topic is a ticker or crypto (e.g. "$NVDA earnings", "bitcoin"), off for everything else.
44 - Trustpilot - brand/company review sentiment - opt-in (add trustpilot to INCLUDE_SOURCES), off by default.
45 - Hacker News + Polymarket + GitHub (auto-on if the gh CLI is installed) - always on, zero config.
46 - X/Twitter - optional. It stays available when you already configured it, or after you explicitly approve a browser-cookie read; skipping it never blocks research.
47
48 Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation.
49
50 Power users can turn on more sources in the Manual Setup guide (LinkedIn, Bluesky, Perplexity, and others) - each needs its own credential, so they are off by default."""
51
52
53 def render_welcome() -> str:
54 """Return the first-run welcome text.
55
56 Owned by the engine (single source of truth) so the model relays it rather
57 than re-authoring it -- authored prose gets skipped, relayed command output
58 does not. Mirrors the SKILL.md welcome; keep in sync if the source set
59 changes.
60 """
61 return _WELCOME_TEXT
62
63
64 # Neutral note recorded on an official-only host instead of a cookie scan.
65 # Deliberately names no cookie mechanism beyond the fact that none is used
66 # (the vocabulary rule for Grok Bot onboarding output).
67 OFFICIAL_HOST_COOKIE_NOTE = "browser sessions are not read on this host"
68
69
70 def run_auto_setup(config: Dict[str, Any], *, allow_browser_cookies: bool = False) -> Dict[str, Any]:
71 """Perform the auto-setup actions.
72
73 - Optionally runs cookie extraction for all registered domains, trying the
74 browsers from ``env.cookie_extraction_browsers()``. Browser reads are off
75 unless ``allow_browser_cookies`` is true.
76 - Checks if yt-dlp is installed
77 - Best-effort install of digg-pp-cli (Printing Press library)
78
79 Returns:
80 Dict with keys:
81 cookies_found: {source_name: browser_name} for each source where cookies were found
82 browser_cookie_scan_attempted: bool (True only after explicit consent
83 AND on a host whose X policy permits cookie discovery)
84 cookie_note: present only on an official-only host, where the scan
85 is skipped for every domain (neutral, relayable text)
86 ytdlp_installed: bool
87 ytdlp_action: already_installed | installed | install_failed | no_homebrew
88 digg_installed: bool (True when the engine can resolve digg-pp-cli on PATH)
89 digg_action: already_installed | installed | installed_off_path | install_failed | no_npx
90 env_written: bool (always False here — caller writes config separately)
91 ytdlp_stderr: present when ytdlp_action is install_failed
92 digg_stderr: present when digg_action is install_failed
93 digg_path: present when digg_action is installed_off_path (binary on disk, not on PATH)
94 """
95 from .env import COOKIE_DOMAINS, cookie_extraction_browsers, x_policy
96
97 cookies_found: Dict[str, str] = {}
98 cookie_note: Optional[str] = None
99
100 # Official-only host (LAST30DAYS_HOST=grok-bot): the consented
101 # cookie scan is skipped for EVERY domain (X and Truth Social alike) and
102 # recorded as not attempted, with a neutral note the caller can relay.
103 # The free CLI installs below still run. A LAST30DAYS_X_BACKEND=bird pin
104 # is the one path that re-enables discovery, via x_policy.
105 if allow_browser_cookies and not x_policy(config).cookie_discovery:
106 allow_browser_cookies = False
107 cookie_note = OFFICIAL_HOST_COOKIE_NOTE
108
109 if allow_browser_cookies:
110 from . import cookie_extract
111
112 cookie_config = dict(config)
113 if not (cookie_config.get("FROM_BROWSER") or "").strip():
114 # Chromium-first: Chrome/Brave/etc. read cookies via the Keychain
115 # with no Full Disk Access, so try them before Safari, whose
116 # binarycookies read requires FDA (the dead-end most users hit).
117 # firefox/safari stay as the silent fallbacks. Note: an explicit
118 # comma list preserves this order (cookie_extraction_browsers);
119 # "auto" would put the silent browsers first, so do not use it here.
120 cookie_config["FROM_BROWSER"] = "chrome,brave,edge,vivaldi,arc,chromium,firefox,safari"
121 browsers = cookie_extraction_browsers(cookie_config)
122
123 for source_name, spec in COOKIE_DOMAINS.items():
124 domain = spec["domain"]
125 cookie_names = spec["cookies"]
126
127 for browser in browsers:
128 try:
129 result = cookie_extract.extract_cookies_with_source(browser, domain, cookie_names)
130 except Exception as exc:
131 logger.debug("Cookie extraction failed for %s via %s: %s", source_name, browser, exc)
132 continue
133 if result is not None and result[0]:
134 cookies_found[source_name] = result[1]
135 break # Found cookies for this service, stop trying browsers
136
137 # Check yt-dlp availability and install via Homebrew if missing. Windows
138 # has no Homebrew, and its working install path is `pip install yt-dlp`
139 # (see #904), so it gets its own no-op-install guidance branch instead of
140 # falling into the Homebrew-oriented no_homebrew outcome.
141 ytdlp_action: str
142 if shutil.which("yt-dlp") is not None:
143 ytdlp_installed = True
144 ytdlp_action = "already_installed"
145 elif os.name == "nt":
146 ytdlp_installed = False
147 ytdlp_action = "no_pip_windows"
148 elif shutil.which("brew") is not None:
149 brew_stderr = ""
150 try:
151 proc = subprocess.run(
152 ["brew", "install", "yt-dlp"],
153 capture_output=True, text=True, timeout=120,
154 )
155 if proc.returncode == 0:
156 ytdlp_installed = True
157 ytdlp_action = "installed"
158 else:
159 ytdlp_installed = False
160 ytdlp_action = "install_failed"
161 brew_stderr = proc.stderr
162 logger.warning("brew install yt-dlp failed: %s", proc.stderr)
163 except Exception as exc:
164 ytdlp_installed = False
165 ytdlp_action = "install_failed"
166 brew_stderr = str(exc)
167 logger.warning("brew install yt-dlp exception: %s", exc)
168 else:
169 ytdlp_installed = False
170 ytdlp_action = "no_homebrew"
171
172 digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli()
173 pp_sources = install_default_pp_sources()
174
175 results: Dict[str, Any] = {
176 "cookies_found": cookies_found,
177 "browser_cookie_scan_attempted": allow_browser_cookies,
178 "ytdlp_installed": ytdlp_installed,
179 "ytdlp_action": ytdlp_action,
180 "digg_installed": digg_installed,
181 "digg_action": digg_action,
182 # Per-CLI status for the additional default-on Printing Press sources
183 # (arxiv, techmeme, trustpilot): {source: {installed, action, ...}}.
184 "pp_sources": pp_sources,
185 # Reported, never installed: this CLI spends the user's own metered
186 # credits, so acquiring it stays their decision. Passing
187 # config matters: a user whose key lives in a .env file or the
188 # keychain (rather than a `brightdata login` credentials file) is
189 # active in the engine, and setup must not tell them otherwise.
190 "brightdata": brightdata_status(config),
191 "env_written": False,
192 }
193 if cookie_note:
194 results["cookie_note"] = cookie_note
195 if ytdlp_action == "install_failed":
196 results["ytdlp_stderr"] = brew_stderr
197 if digg_action == "install_failed":
198 results["digg_stderr"] = digg_stderr
199 if digg_path:
200 results["digg_path"] = digg_path
201 return results
202
203
204 # Generous timeout: the install shells out to `npx`, which may download the
205 # Printing Press package and build the Go binary over the network.
206 DIGG_INSTALL_TIMEOUT = 300
207 DIGG_CLI_BIN = "digg-pp-cli"
208 # Pin the catalog installer; matches printing-press-library npm 0.1.16 default
209 # ($HOME/.local/bin on macOS/Linux).
210 PRINTING_PRESS_NPM = "@mvanhorn/printing-press-library@0.1.16"
211 DIGG_INSTALL_CMD = f"npx -y {PRINTING_PRESS_NPM} install digg --cli-only"
212
213
214 def _digg_bin_candidate_paths() -> list[Path]:
215 """Known install locations for digg-pp-cli (Printing Press library defaults).
216
217 Order: current installer default (~/.local/bin), legacy Go bins, Windows
218 managed dir. The directory list is ``health.installer_bin_dirs()`` — the
219 shared single source — with the Digg filename variants appended (plain
220 name for Unix-style dirs, ``.exe`` in the Windows managed dir).
221 ``pipeline.available_sources()`` only activates Digg when
222 ``shutil.which`` resolves on PATH — probing these dirs is for setup
223 verification and honest off-PATH messaging, not engine activation.
224 """
225 from . import health
226
227 win_dir = health.windows_printing_press_bin_dir()
228 candidates: list[Path] = []
229 for directory in health.installer_bin_dirs():
230 if win_dir is not None and directory == win_dir:
231 candidates.append(directory / f"{DIGG_CLI_BIN}.exe")
232 else:
233 candidates.append(directory / DIGG_CLI_BIN)
234 return candidates
235
236
237 def _digg_on_path() -> Optional[str]:
238 """Return digg-pp-cli when the engine would activate Digg (PATH-resolvable)."""
239 return shutil.which(DIGG_CLI_BIN)
240
241
242 def _digg_off_path_binary() -> Optional[str]:
243 """Return digg-pp-cli path from known install dirs when not on PATH."""
244 for candidate in _digg_bin_candidate_paths():
245 if candidate.is_file() and os.access(candidate, os.X_OK):
246 return str(candidate)
247 return None
248
249
250 def _digg_bin_dir_hint(digg_path: str) -> str:
251 """Return a copy-pasteable PATH directory for the given binary path."""
252 parent = os.path.dirname(os.path.expanduser(digg_path))
253 if os.name == "nt":
254 # Windows PATH edits use absolute dirs; $HOME is a Unix shell convention.
255 return parent
256 home = str(Path.home())
257 if parent == home:
258 return "$HOME"
259 prefix = home + os.sep
260 if parent.startswith(prefix):
261 rel = parent[len(prefix):].replace(os.sep, "/")
262 return f"$HOME/{rel}" if rel else "$HOME"
263 return parent
264
265
266 def _run_npx_install(slug: str) -> Tuple[str, str]:
267 """Resolve ``npx`` and run the Printing Press catalog install for ``slug``.
268
269 Shared by ``_install_digg_cli`` and ``_install_pp_cli`` -- this is only the
270 "resolve npx, run the install, interpret no_npx/exception/nonzero-rc"
271 slice; each caller keeps its own on-path/off-path re-verification
272 (``_digg_bin_candidate_paths`` vs ``_pp_bin_candidate_paths`` already use
273 different candidate-directory sources, so merging them here would change
274 off-path detection behavior beyond this fix's scope).
275
276 Fixes the Windows PATHEXT mismatch: ``shutil.which("npx")`` resolves
277 ``npx.CMD`` via PATHEXT, but ``subprocess.run`` given the bare string
278 ``"npx"`` as argv[0] does not do that resolution and fails with
279 ``WinError 2``. Passing the resolved path is a no-op on macOS/Linux, where
280 ``shutil.which`` already returns the exact path ``CreateProcess``/``execve``
281 would resolve.
282
283 Returns ``(action, stderr)``: ``action`` is ``"no_npx"``,
284 ``"install_failed"``, or ``""`` when the subprocess ran and returned
285 ``rc=0`` (in which case ``stderr`` carries any non-fatal stderr output for
286 the caller's own off-path logging).
287 """
288 npx = shutil.which("npx")
289 if npx is None:
290 return "no_npx", ""
291 try:
292 proc = subprocess.run(
293 [npx, "-y", PRINTING_PRESS_NPM, "install", slug, "--cli-only"],
294 capture_output=True, text=True, timeout=DIGG_INSTALL_TIMEOUT,
295 )
296 except Exception as exc:
297 logger.warning("npx install %s exception: %s", slug, exc)
298 return "install_failed", str(exc)
299 if proc.returncode != 0:
300 stderr = proc.stderr or f"npx install {slug} exited {proc.returncode}"
301 logger.warning("npx install %s failed (rc=%s): %s", slug, proc.returncode, stderr)
302 return "install_failed", stderr
303 return "", (proc.stderr or "")
304
305
306 def _install_digg_cli() -> Tuple[bool, str, str, str]:
307 """Best-effort install of the digg-pp-cli binary.
308
309 Mirrors the yt-dlp/brew auto-install: it never raises, and degrades to a
310 recommend-only outcome when the installer is unavailable. Uses
311 ``@mvanhorn/printing-press-library`` (``--cli-only``) — the same catalog
312 installer as pp-digg; Hermes/OpenClaw skill wiring is irrelevant here.
313
314 Returns ``(engine_active, action, stderr, off_path_binary)`` where
315 ``engine_active`` is True only when ``shutil.which`` resolves the binary
316 (matching ``pipeline.available_sources()``). ``action`` is one of:
317 already_installed | installed | installed_off_path | install_failed | no_npx
318 ``stderr`` is populated on ``install_failed``. ``off_path_binary`` is set
319 when the binary exists on disk but is not PATH-visible to this process.
320 """
321 on_path = _digg_on_path()
322 if on_path:
323 return True, "already_installed", "", ""
324 off_path = _digg_off_path_binary()
325 if off_path:
326 return False, "installed_off_path", "", off_path
327 action, stderr = _run_npx_install("digg")
328 if action:
329 return False, action, stderr, ""
330 on_path = _digg_on_path()
331 if on_path:
332 return True, "installed", "", ""
333 off_path = _digg_off_path_binary()
334 if off_path:
335 combined = stderr.strip()
336 if combined:
337 logger.warning("digg-pp-cli installed off PATH: %s", combined)
338 return False, "installed_off_path", combined, off_path
339 stderr_msg = stderr or "install completed but digg-pp-cli was not found"
340 logger.warning("npx install digg failed verification: %s", stderr_msg)
341 return False, "install_failed", stderr_msg, ""
342
343
344 # Additional default-on Printing Press sources installed the same way as Digg:
345 # (engine source key, slug for `install <slug>`, binary name). These activate in
346 # ``pipeline.available_sources()`` when ``shutil.which`` resolves the binary.
347 # Trustpilot is intentionally NOT here: it is opt-in (INCLUDE_SOURCES=trustpilot)
348 # because of its headless-Chrome cookie harvest, so auto-installing its binary
349 # for a source that stays off by default would be wasted work. Opting in installs
350 # it on demand via `npx ... install trustpilot --cli-only` (see CONFIGURATION.md).
351 PP_DEFAULT_SOURCES: list[tuple[str, str, str]] = [
352 ("arxiv", "arxiv", "arxiv-pp-cli"),
353 ("techmeme", "techmeme", "techmeme-pp-cli"),
354 ]
355
356 # Bright Data is deliberately absent from PP_DEFAULT_SOURCES: it is not a
357 # Printing Press CLI, it is opt-in like Trustpilot, and it spends the user's
358 # own metered credits. Setup reports its state and never installs it.
359 BRIGHTDATA_BIN = "brightdata"
360
361
362 def _brightdata_off_path_binary() -> Optional[str]:
363 """Locate a brightdata binary that exists on disk but not on PATH.
364
365 Covers the common npm global prefixes. The distinction matters because
366 Hermes and OpenClaw gateways routinely run the engine with a PATH that
367 excludes the user's npm bin directory, so "installed" and "the engine
368 can see it" are different questions.
369 """
370 home = Path.home()
371 candidates = [
372 home / ".local" / "bin" / BRIGHTDATA_BIN,
373 home / ".npm-global" / "bin" / BRIGHTDATA_BIN,
374 Path("/opt/homebrew/bin") / BRIGHTDATA_BIN,
375 Path("/usr/local/bin") / BRIGHTDATA_BIN,
376 ]
377 npm_prefix = os.environ.get("NPM_CONFIG_PREFIX")
378 if npm_prefix:
379 candidates.insert(0, Path(npm_prefix) / "bin" / BRIGHTDATA_BIN)
380 for candidate in candidates:
381 try:
382 if candidate.is_file() and os.access(candidate, os.X_OK):
383 return str(candidate)
384 except OSError:
385 continue
386 return None
387
388
389 def brightdata_status(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
390 """Report the Bright Data install and auth state honestly.
391
392 Deliberately never claims the source is active unless the engine's own
393 gate would pass -- ``brightdata.is_available`` is the single predicate,
394 so setup and the engine cannot drift apart. Three states matter:
395
396 * ``already_installed`` -- on PATH; ``authenticated`` says whether the
397 amazon lane will actually run.
398 * ``installed_off_path`` -- on disk but invisible to the engine, which
399 is the Hermes/OpenClaw failure mode. Carries the path so the user can
400 fix their PATH.
401 * ``not_installed`` -- nothing found. No auto-install: this CLI
402 spends the user's metered credits, so acquiring it is their call.
403 """
404 installed = brightdata.is_installed()
405 authenticated = brightdata.has_credentials(config)
406 if installed:
407 action = "already_installed"
408 off_path = ""
409 else:
410 off_path = _brightdata_off_path_binary() or ""
411 action = "installed_off_path" if off_path else "not_installed"
412
413 status: Dict[str, Any] = {
414 "installed": installed,
415 "action": action,
416 "authenticated": installed and authenticated,
417 # The engine gate, verbatim. Never report active on anything else.
418 "engine_active": brightdata.is_available(config),
419 }
420 if off_path:
421 status["path"] = off_path
422 status["hint"] = (
423 f"brightdata found at {off_path} but not on PATH; add its directory "
424 "to PATH so the engine subprocess can see it"
425 )
426 elif installed and not authenticated:
427 status["hint"] = "run `brightdata login` to activate the amazon source"
428 elif not installed:
429 status["hint"] = (
430 "install with `npm i -g @brightdata/cli` then `brightdata login` "
431 "to enable the amazon source"
432 )
433 return status
434
435
436 def _pp_bin_candidate_paths(bin_name: str) -> list[Path]:
437 """Known install locations for a Printing Press CLI binary (slug-parameterized
438 mirror of ``_digg_bin_candidate_paths``)."""
439 home = Path.home()
440 candidates: list[Path] = [home / ".local" / "bin" / bin_name]
441 gopath = os.environ.get("GOPATH")
442 if gopath:
443 candidates.append(Path(gopath) / "bin" / bin_name)
444 candidates.append(home / "go" / "bin" / bin_name)
445 if os.name == "nt":
446 local_app = os.environ.get("LOCALAPPDATA") or os.environ.get("LocalAppData")
447 if local_app:
448 candidates.append(
449 Path(local_app) / "Programs" / "PrintingPress" / "bin" / f"{bin_name}.exe"
450 )
451 return candidates
452
453
454 def _pp_off_path_binary(bin_name: str) -> Optional[str]:
455 for candidate in _pp_bin_candidate_paths(bin_name):
456 if candidate.is_file() and os.access(candidate, os.X_OK):
457 return str(candidate)
458 return None
459
460
461 def _install_pp_cli(slug: str, bin_name: str) -> Tuple[bool, str, str, str]:
462 """Best-effort install of a Printing Press CLI binary.
463
464 Slug-parameterized mirror of ``_install_digg_cli``: never raises, degrades
465 to recommend-only when the installer is unavailable. Returns
466 ``(engine_active, action, stderr, off_path_binary)`` with the same action
467 taxonomy: already_installed | installed | installed_off_path |
468 install_failed | no_npx.
469 """
470 on_path = shutil.which(bin_name)
471 if on_path:
472 return True, "already_installed", "", ""
473 off_path = _pp_off_path_binary(bin_name)
474 if off_path:
475 return False, "installed_off_path", "", off_path
476 action, stderr = _run_npx_install(slug)
477 if action:
478 return False, action, stderr, ""
479 on_path = shutil.which(bin_name)
480 if on_path:
481 return True, "installed", "", ""
482 off_path = _pp_off_path_binary(bin_name)
483 if off_path:
484 combined = stderr.strip()
485 if combined:
486 logger.warning("%s installed off PATH: %s", bin_name, combined)
487 return False, "installed_off_path", combined, off_path
488 stderr_msg = stderr or f"install completed but {bin_name} was not found"
489 logger.warning("npx install %s failed verification: %s", slug, stderr_msg)
490 return False, "install_failed", stderr_msg, ""
491
492
493 def install_default_pp_sources() -> Dict[str, Dict[str, Any]]:
494 """Best-effort install of every additional default-on Printing Press source.
495
496 Returns ``{source_key: {installed, action, stderr?, path?}}`` so the wizard
497 can report per-CLI status alongside Digg without raising on any single
498 failure.
499 """
500 out: Dict[str, Dict[str, Any]] = {}
501 for source_key, slug, bin_name in PP_DEFAULT_SOURCES:
502 installed, action, stderr, off_path = _install_pp_cli(slug, bin_name)
503 entry: Dict[str, Any] = {"installed": installed, "action": action}
504 if action == "install_failed" and stderr:
505 entry["stderr"] = stderr
506 if off_path:
507 entry["path"] = off_path
508 out[source_key] = entry
509 return out
510
511
512 def _open_secret_append(path: Path):
513 """Open ``path`` for appending as a 0o600 secret file with no readable window.
514
515 ``os.open`` with ``O_CREAT|O_WRONLY|O_APPEND`` and mode ``0o600`` sets
516 restrictive permissions at creation (umask can only further restrict, never
517 widen, so the file is never world-readable even transiently). An explicit
518 ``chmod`` afterwards also tightens a pre-existing loose file. This matters
519 because the .env stores API keys, cookies, and tokens.
520 """
521 fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
522 try:
523 os.chmod(path, 0o600)
524 except OSError:
525 pass
526 return os.fdopen(fd, "a", encoding="utf-8")
527
528
529 def _replace_env_line(env_path: Path, content: str, key_name: str, value: str) -> bool:
530 """Rewrite every ``key_name=`` line of ``content`` with ``value`` as a 0o600 secret.
531
532 The new file is written to a sibling temp path opened at 0o600 and moved
533 over the original, so the secret never has a readable window and a
534 crash mid-write leaves the old file intact.
535 """
536 new_line = f"{key_name}={_format_env_value(value)}"
537 lines = []
538 replaced = False
539 for line in content.splitlines():
540 stripped = line.strip()
541 is_key = (
542 stripped and not stripped.startswith("#") and "=" in stripped
543 and stripped.split("=", 1)[0].strip() == key_name
544 )
545 if is_key:
546 if not replaced:
547 lines.append(new_line)
548 replaced = True
549 continue
550 lines.append(line)
551 if not replaced:
552 lines.append(new_line)
553 tmp_path = env_path.with_name(env_path.name + ".tmp")
554 fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
555 with os.fdopen(fd, "w", encoding="utf-8") as f:
556 f.write("\n".join(lines) + "\n")
557 os.replace(tmp_path, env_path)
558 try:
559 os.chmod(env_path, 0o600)
560 except OSError:
561 pass
562 return True
563
564
565 def _format_env_value(value: str) -> str:
566 """Quote a value so it round-trips through env.load_env_file.
567
568 env.load_env_file strips a single layer of matching surrounding quotes but
569 does NOT process backslash escapes, so we wrap (never escape):
570 - plain tokens (no whitespace, no leading quote): returned unchanged;
571 - values with whitespace/leading quote and no double-quote: double-quoted;
572 - values containing a double-quote but no single-quote: single-quoted;
573 - values containing both quote types: returned as-is (best effort; no
574 wrapper round-trips through the loader, and tokens never hit this).
575 Newlines are not valid in a single-line env value and are stripped.
576 """
577 value = value.replace("\r", "").replace("\n", " ")
578 needs_quoting = (not value) or value[0] in ("'", '"') or any(c.isspace() for c in value)
579 if not needs_quoting:
580 return value
581 if '"' not in value:
582 return f'"{value}"'
583 if "'" not in value:
584 return f"'{value}'"
585 return value
586
587
588 def write_setup_config(env_path: Path, from_browser: str | None = None) -> bool:
589 """Write SETUP_COMPLETE and FROM_BROWSER to the .env file.
590
591 Creates the file and parent directories if needed.
592 Appends to existing file without overwriting existing keys.
593
594 Args:
595 env_path: Path to the .env file (e.g. ~/.config/last30days/.env)
596 from_browser: Browser extraction mode to persist. Pass the browser that
597 actually yielded cookies (e.g. "firefox") to fast-path future runs.
598 Pass None (default) to NOT pin FROM_BROWSER — the steady-state
599 default (Firefox/Safari, no Keychain prompt) then applies. We avoid
600 persisting "auto" because it makes every later run probe Chrome and
601 re-trigger the Keychain prompt.
602
603 Returns:
604 True if config was written successfully, False on error.
605 """
606 try:
607 env_path = Path(env_path)
608 env_path.parent.mkdir(parents=True, exist_ok=True)
609
610 # Read existing content to avoid overwriting keys
611 existing_keys: set = set()
612 existing_content = ""
613 if env_path.exists():
614 existing_content = env_path.read_text(encoding="utf-8")
615 for line in existing_content.splitlines():
616 stripped = line.strip()
617 if stripped and not stripped.startswith("#") and "=" in stripped:
618 key = stripped.split("=", 1)[0].strip()
619 existing_keys.add(key)
620
621 lines_to_add = []
622 if "SETUP_COMPLETE" not in existing_keys:
623 lines_to_add.append("SETUP_COMPLETE=true")
624 if from_browser and "FROM_BROWSER" not in existing_keys:
625 lines_to_add.append(f"FROM_BROWSER={_format_env_value(from_browser)}")
626
627 if not lines_to_add:
628 return True # Nothing to write, already configured
629
630 # Create/append as a 0o600 secret file: the .env holds tokens and keys,
631 # so it must never be created world-readable.
632 with _open_secret_append(env_path) as f:
633 if existing_content and not existing_content.endswith("\n"):
634 f.write("\n")
635 f.write("\n".join(lines_to_add) + "\n")
636
637 return True
638
639 except OSError as exc:
640 logger.error("Failed to write setup config to %s: %s", env_path, exc)
641 return False
642
643
644 def write_api_key(
645 env_path: Path,
646 api_key: str,
647 key_name: str = "SCRAPECREATORS_API_KEY",
648 *,
649 replace: bool = False,
650 ) -> bool:
651 """Append an API key to the .env file as a 0o600 secret.
652
653 Reuses the same secret-safe write path as ``write_setup_config`` so the
654 value lands with restrictive permissions and round-trips through
655 ``env.load_env_file``. Idempotent by default: if ``key_name`` is already
656 present in the file, nothing is written and the existing value is
657 preserved (we never clobber a key the user may have set by hand). With
658 ``replace=True`` an existing line is rewritten in place instead, so an
659 explicit ``setup --store-key`` can rotate a rejected credential.
660
661 Args:
662 env_path: Path to the .env file (e.g. ~/.config/last30days/.env).
663 api_key: The raw key value to persist.
664 key_name: The env var name to write (default SCRAPECREATORS_API_KEY).
665 replace: Rewrite an existing ``key_name`` line instead of keeping it.
666
667 Returns:
668 True if the key was written or already present, False on error or when
669 ``api_key`` is empty.
670 """
671 if not api_key:
672 return False
673 try:
674 env_path = Path(env_path)
675 env_path.parent.mkdir(parents=True, exist_ok=True)
676
677 existing_content = ""
678 if env_path.exists():
679 existing_content = env_path.read_text(encoding="utf-8")
680 for line in existing_content.splitlines():
681 stripped = line.strip()
682 if stripped and not stripped.startswith("#") and "=" in stripped:
683 if stripped.split("=", 1)[0].strip() == key_name:
684 if replace:
685 return _replace_env_line(env_path, existing_content, key_name, api_key)
686 return True # Already configured; do not duplicate
687
688 line = f"{key_name}={_format_env_value(api_key)}\n"
689 with _open_secret_append(env_path) as f:
690 if existing_content and not existing_content.endswith("\n"):
691 f.write("\n")
692 f.write(line)
693
694 return True
695
696 except OSError as exc:
697 logger.error("Failed to write API key to %s: %s", env_path, exc)
698 return False
699
700
701 def mask_api_key(api_key: str) -> str:
702 """Return a non-secret display form of an API key (prefix + last 4).
703
704 Used so the key never appears verbatim in stdout the host model captures.
705 Short or empty keys collapse to a fixed placeholder.
706 """
707 if not api_key or len(api_key) <= 8:
708 return "sc_…"
709 return f"{api_key[:3]}…{api_key[-4:]}"
710
711
712 def get_setup_status_text(results: Dict[str, Any]) -> str:
713 """Return a human-readable summary of auto-setup results.
714
715 Args:
716 results: Dict from run_auto_setup()
717
718 Returns:
719 Multi-line status text.
720 """
721 lines = []
722 lines.append("Setup complete! Here's what I found:")
723 lines.append("")
724
725 cookies_found = results.get("cookies_found", {})
726 if results.get("browser_cookie_scan_attempted") and cookies_found:
727 for source, browser in cookies_found.items():
728 lines.append(f" - {source.upper()} cookies found in {browser}")
729
730 ytdlp_action = results.get("ytdlp_action", "")
731 if ytdlp_action == "installed":
732 lines.append(" - Installed yt-dlp via Homebrew")
733 elif ytdlp_action == "install_failed":
734 lines.append(" - yt-dlp install failed \u2014 run `brew install yt-dlp` manually")
735 elif ytdlp_action == "no_homebrew":
736 lines.append(" - yt-dlp not found. Install Homebrew first, then: brew install yt-dlp")
737 elif ytdlp_action == "no_pip_windows":
738 lines.append(
739 " - yt-dlp not found. Install with: pip install yt-dlp "
740 "(it may install to a Scripts directory not on PATH -- add it to PATH if YouTube search stays inactive)"
741 )
742 elif ytdlp_action == "already_installed":
743 lines.append(" - yt-dlp already installed")
744 elif results.get("ytdlp_installed", False):
745 lines.append(" - yt-dlp is installed (YouTube search ready)")
746 else:
747 lines.append(" - yt-dlp not found (install with: brew install yt-dlp)")
748
749 digg_action = results.get("digg_action", "")
750 if digg_action == "installed":
751 lines.append(" - Installed Digg CLI (free AI-news clusters source now active)")
752 elif digg_action == "already_installed":
753 lines.append(" - Digg CLI already installed (AI-news clusters active)")
754 elif digg_action == "installed_off_path":
755 digg_path = results.get("digg_path", "")
756 if digg_path:
757 bin_dir = _digg_bin_dir_hint(digg_path)
758 lines.append(
759 f" - Digg CLI found at {digg_path} but not on PATH — add "
760 f"{bin_dir} to PATH and restart your agent session/gateway "
761 "for Digg to activate"
762 )
763 else:
764 lines.append(
765 " - Digg CLI is installed but not on PATH — add its install "
766 "directory to PATH and restart your agent session/gateway for "
767 "Digg to activate"
768 )
769 elif digg_action == "install_failed":
770 lines.append(f" - Digg CLI install failed — run `{DIGG_INSTALL_CMD}` manually")
771 elif digg_action == "no_npx":
772 lines.append(
773 " - Digg CLI not installed (free, optional). Install Node/npx, then: "
774 f"{DIGG_INSTALL_CMD}"
775 )
776
777 pp_sources = results.get("pp_sources", {})
778 pp_name: dict[str, str] = {"arxiv": "arXiv", "techmeme": "Techmeme"}
779 for source_key, entry in sorted(pp_sources.items()):
780 name = pp_name.get(source_key, source_key.title())
781 action = entry.get("action", "")
782 if action == "installed":
783 lines.append(f" - Installed {name} CLI ({name} source now active)")
784 elif action == "already_installed":
785 lines.append(f" - {name} CLI already installed ({name} active)")
786 elif action == "installed_off_path":
787 path = entry.get("path", "")
788 if path:
789 lines.append(
790 f" - {name} CLI at {path} but not on PATH — add "
791 f"{os.path.dirname(os.path.expanduser(path))} to PATH and "
792 f"restart your agent session/gateway for {name} to activate"
793 )
794 else:
795 lines.append(
796 f" - {name} CLI installed but not on PATH — add its install "
797 "directory to PATH and restart your agent session/gateway for "
798 f"{name} to activate"
799 )
800 elif action == "install_failed":
801 lines.append(
802 f" - {name} CLI install failed — run "
803 f"`npx -y {PRINTING_PRESS_NPM} install {source_key} --cli-only` manually"
804 )
805 elif action == "no_npx":
806 lines.append(
807 f" - {name} CLI not installed (free, optional). Install Node/npx, "
808 f"then: `npx -y {PRINTING_PRESS_NPM} install {source_key} --cli-only`"
809 )
810
811 # Bright Data / Amazon. Reported but never installed (it spends the user's
812 # own metered credits), so the only useful thing setup can do is say
813 # precisely why the lane is or is not active -- the three states below are
814 # otherwise invisible, since SKILL.md tells the model not to raise the
815 # subject mid-run.
816 brightdata_status_entry = results.get("brightdata") or {}
817 bd_action = brightdata_status_entry.get("action", "")
818 if brightdata_status_entry.get("engine_active"):
819 lines.append(" - Bright Data CLI ready (Amazon buyer signals available)")
820 elif bd_action == "already_installed":
821 lines.append(
822 " - Bright Data CLI installed but not logged in — run "
823 "`brightdata login` to enable Amazon buyer signals (optional)"
824 )
825 elif bd_action == "installed_off_path":
826 bd_path = brightdata_status_entry.get("path", "")
827 lines.append(
828 f" - Bright Data CLI found at {bd_path} but not on PATH — add "
829 f"{os.path.dirname(os.path.expanduser(bd_path))} to PATH and restart "
830 "your agent session/gateway for Amazon buyer signals to activate"
831 )
832 elif bd_action == "not_installed":
833 lines.append(
834 " - Amazon buyer signals not installed (optional; 5,000 free "
835 "requests/month). Install with: npm i -g @brightdata/cli && brightdata login"
836 )
837
838 cookie_note = results.get("cookie_note")
839 if cookie_note:
840 # Official-only host: relay the neutral note; say nothing about
841 # browsers. The scan was not attempted, so nothing is "found".
842 lines.append(f" - {cookie_note}")
843
844 env_written = results.get("env_written", False)
845 if env_written:
846 lines.append("")
847 if cookie_note:
848 lines.append("Configuration saved.")
849 else:
850 lines.append("Configuration saved. Future runs will auto-detect your browsers.")
851
852 return "\n".join(lines)
853
854
855 # ---------------------------------------------------------------------------
856 # OpenClaw server-side setup (no browser, JSON output)
857 # ---------------------------------------------------------------------------
858
859 _OPENCLAW_KEY_NAMES = [
860 "SCRAPECREATORS_API_KEY",
861 "XAI_API_KEY",
862 "BRAVE_API_KEY",
863 "EXA_API_KEY",
864 "SERPER_API_KEY",
865 "OPENAI_API_KEY",
866 "AUTH_TOKEN",
867 ]
868
869
870 def run_openclaw_setup(config: Dict[str, Any]) -> Dict[str, Any]:
871 """Server-side setup probe: no cookies, tool + key availability, Digg CLI.
872
873 Best-effort installs digg-pp-cli when npx is available (same as desktop
874 ``run_auto_setup``). Returns a dict suitable for JSON output to stdout so
875 that SKILL.md can present appropriate options to the user.
876 """
877 yt_dlp = shutil.which("yt-dlp") is not None
878 node = shutil.which("node") is not None
879 python3 = shutil.which("python3") is not None
880
881 digg_installed, digg_action, digg_stderr, digg_path = _install_digg_cli()
882
883 keys: Dict[str, bool] = {}
884 for key_name in _OPENCLAW_KEY_NAMES:
885 short = key_name.lower().replace("_api_key", "").replace("_key", "").replace("_token", "")
886 # Normalize: AUTH_TOKEN -> auth, SCRAPECREATORS_API_KEY -> scrapecreators
887 keys[short] = bool(config.get(key_name))
888
889 # Determine x_method
890 if config.get("XAI_API_KEY"):
891 x_method: Optional[str] = "xai"
892 elif config.get("AUTH_TOKEN") and config.get("CT0"):
893 x_method = "cookies"
894 else:
895 x_method = None
896
897 payload: Dict[str, Any] = {
898 "yt_dlp": yt_dlp,
899 "node": node,
900 "python3": python3,
901 "digg_cli": digg_installed,
902 "digg_action": digg_action,
903 "keys": keys,
904 "x_method": x_method,
905 }
906 if digg_path:
907 payload["digg_path"] = digg_path
908 if digg_action == "install_failed" and digg_stderr:
909 payload["digg_stderr"] = digg_stderr
910 return payload
911
912
913 # ---------------------------------------------------------------------------
914 # Device auth flow (GitHub OAuth via ScrapeCreators)
915 # ---------------------------------------------------------------------------
916
917 _DEVICE_BASE = "https://api.scrapecreators.com/v1/github/device"
918
919 # A GitHub device code is always XXXX-XXXX (uppercase alphanumerics). We validate
920 # user_code against this before copying, labeling, or emitting it so a malformed
921 # or key-shaped value (e.g. a returning-account server response) is never
922 # mislabeled as a device code or leaked to stdout/clipboard.
923 _DEVICE_CODE_RE = re.compile(r"^[0-9A-Z]{4}-[0-9A-Z]{4}$")
924
925
926 def _existing_scrapecreators_key() -> Optional[str]:
927 """Return the SCRAPECREATORS_API_KEY already saved in the .env, if any."""
928 try:
929 from . import env as _env
930
931 if _env.CONFIG_FILE and _env.CONFIG_FILE.exists():
932 return _env.load_env_file(_env.CONFIG_FILE).get("SCRAPECREATORS_API_KEY") or None
933 except Exception as exc: # never let a config-read failure block auth
934 logger.debug("Could not read existing ScrapeCreators key: %s", exc)
935 return None
936
937
938 def run_device_auth() -> Optional[Tuple[str, str, str, int]]:
939 """Start the device authorization flow.
940
941 POSTs to the ScrapeCreators device/code endpoint.
942
943 Returns:
944 (device_code, user_code, verification_uri, interval) on success,
945 None on failure.
946 """
947 try:
948 body = json.dumps({}).encode()
949 req = Request(f"{_DEVICE_BASE}/code", data=body, method="POST")
950 req.add_header("Content-Type", "application/json")
951 with urlopen(req, timeout=15) as resp:
952 data = json.loads(resp.read())
953 except (HTTPError, URLError, OSError) as exc:
954 logger.warning("Device auth code request failed: %s", exc)
955 return None
956
957 device_code = data.get("device_code")
958 user_code = data.get("user_code")
959 verification_uri = data.get("verification_uri")
960 interval = data.get("interval", 5)
961
962 if not device_code or not user_code:
963 # Log only the response's key names, never its values — a returning
964 # account's response could carry a raw API key we must not write to logs.
965 logger.warning(
966 "Device auth returned incomplete response (keys: %s)", sorted(data.keys())
967 )
968 return None
969
970 return (device_code, user_code, verification_uri or "", interval)
971
972
973 def poll_device_auth(
974 device_code: str,
975 interval: int,
976 timeout: int = 300,
977 user_code: str = "",
978 clipboard_ok: bool = False,
979 ) -> Optional[str]:
980 """Poll for an access token after the user authorizes the device.
981
982 Args:
983 device_code: The device_code from run_device_auth().
984 interval: Polling interval in seconds.
985 timeout: Maximum time to poll in seconds.
986 user_code: The user code to remind about during polling.
987 clipboard_ok: Whether the code was copied to clipboard.
988
989 Returns:
990 access_token on success, None on timeout or failure.
991 """
992 import sys
993
994 started_at = time.time()
995 deadline = started_at + timeout
996 last_reminder = started_at
997 reminder_count = 0
998 max_reminders = 4
999 reminder_interval = 30 # seconds between reminders
1000
1001 while time.time() < deadline:
1002 time.sleep(interval)
1003
1004 # Periodic reminder of the code while waiting
1005 if (
1006 user_code
1007 and reminder_count < max_reminders
1008 and time.time() - last_reminder >= reminder_interval
1009 ):
1010 clipboard_hint = " (on your clipboard)" if clipboard_ok else ""
1011 print(
1012 f" Still waiting... Your code: {user_code}{clipboard_hint}",
1013 file=sys.stderr,
1014 flush=True,
1015 )
1016 last_reminder = time.time()
1017 reminder_count += 1
1018
1019 try:
1020 body = json.dumps({"device_code": device_code}).encode()
1021 req = Request(f"{_DEVICE_BASE}/token", data=body, method="POST")
1022 req.add_header("Content-Type", "application/json")
1023 with urlopen(req, timeout=15) as resp:
1024 data = json.loads(resp.read())
1025 except HTTPError as exc:
1026 if exc.code in (400, 403, 428):
1027 continue
1028 logger.warning("Device auth poll error: %s", exc)
1029 return None
1030 except (URLError, OSError):
1031 continue
1032
1033 if data.get("access_token"):
1034 return data["access_token"]
1035
1036 error = data.get("error")
1037 if error == "slow_down":
1038 interval = min(interval + 2, 30)
1039 continue
1040 if error == "authorization_pending":
1041 continue
1042 if error in ("expired_token", "access_denied"):
1043 logger.warning("Device auth failed: %s", error)
1044 return None
1045
1046 return None
1047
1048
1049 # Bounded retries for transient ScrapeCreators /profile 5xx (see #882).
1050 _PROFILE_FETCH_ATTEMPTS = 3
1051 _PROFILE_FETCH_RETRY_SLEEP_S = 1.0
1052 _PROFILE_ERROR_BODY_LIMIT = 200
1053
1054
1055 def _http_error_detail(exc: HTTPError, *, body_limit: int = _PROFILE_ERROR_BODY_LIMIT) -> str:
1056 """Build a diagnosable HTTPError string including a truncated body.
1057
1058 The body is capped and never treated as a secret source of truth; callers
1059 still must not log bearer tokens. Used so a 5xx is not a black box.
1060 """
1061 base = f"HTTP Error {exc.code}: {getattr(exc, 'reason', '') or ''}".rstrip(": ")
1062 try:
1063 raw = exc.read() or b""
1064 except Exception:
1065 return base
1066 if not raw:
1067 return base
1068 text = raw.decode("utf-8", errors="replace").strip()
1069 if not text:
1070 return base
1071 if len(text) > body_limit:
1072 text = text[:body_limit] + "…"
1073 return f"{base} body={text!r}"
1074
1075
1076 def fetch_api_key(access_token: str) -> Dict[str, Any]:
1077 """Fetch the ScrapeCreators API key using the GitHub access token.
1078
1079 GETs the device/profile endpoint with Bearer auth. Distinguishes outcomes
1080 so callers (and SKILL.md) do not collapse a server 5xx into the
1081 already-linked guidance (#882).
1082
1083 Returns a result dict:
1084 - ``{"ok": True, "api_key": str}`` on success
1085 - ``{"ok": False, "reason": "no_api_key"}`` when /profile is 2xx but has
1086 no ``api_key`` field (typical already-linked account)
1087 - ``{"ok": False, "reason": "upstream_error", "http_status": int,
1088 "detail": str}`` on 5xx after bounded retries
1089 - ``{"ok": False, "reason": "http_error", "http_status": int,
1090 "detail": str}`` on other HTTP errors (e.g. 401)
1091 - ``{"ok": False, "reason": "request_failed", "detail": str}`` on
1092 network/OS failures
1093 """
1094 data: Optional[Dict[str, Any]] = None
1095 last_upstream: Optional[Dict[str, Any]] = None
1096
1097 for attempt in range(_PROFILE_FETCH_ATTEMPTS):
1098 try:
1099 req = Request(f"{_DEVICE_BASE}/profile")
1100 req.add_header("Authorization", f"Bearer {access_token}")
1101 with urlopen(req, timeout=15) as resp:
1102 data = json.loads(resp.read())
1103 break
1104 except HTTPError as exc:
1105 detail = _http_error_detail(exc)
1106 if 500 <= int(exc.code) <= 599:
1107 logger.warning(
1108 "Device auth /profile upstream error (attempt %s/%s): %s",
1109 attempt + 1,
1110 _PROFILE_FETCH_ATTEMPTS,
1111 detail,
1112 )
1113 last_upstream = {
1114 "ok": False,
1115 "reason": "upstream_error",
1116 "http_status": int(exc.code),
1117 "detail": detail,
1118 }
1119 if attempt + 1 < _PROFILE_FETCH_ATTEMPTS:
1120 time.sleep(_PROFILE_FETCH_RETRY_SLEEP_S)
1121 continue
1122 return last_upstream
1123 logger.warning("Failed to fetch API key: %s", detail)
1124 return {
1125 "ok": False,
1126 "reason": "http_error",
1127 "http_status": int(exc.code),
1128 "detail": detail,
1129 }
1130 except (URLError, OSError) as exc:
1131 logger.warning("Failed to fetch API key: %s", exc)
1132 return {"ok": False, "reason": "request_failed", "detail": str(exc)}
1133
1134 if data is None:
1135 # Defensive: loop exited without success or an explicit return.
1136 return last_upstream or {
1137 "ok": False,
1138 "reason": "request_failed",
1139 "detail": "No profile response",
1140 }
1141
1142 api_key = data.get("api_key")
1143 if not api_key:
1144 # The /profile response parsed but carried no api_key — the common case
1145 # for a GitHub account already linked to a ScrapeCreators account. Log
1146 # the response's FIELD NAMES only (never values — the body may contain a
1147 # key under a different field) so the already-registered response shape
1148 # can be handled in a follow-up (see plan OQ1).
1149 logger.warning(
1150 "Device auth /profile returned no api_key (fields: %s)", sorted(data.keys())
1151 )
1152 return {"ok": False, "reason": "no_api_key"}
1153 return {"ok": True, "api_key": api_key}
1154
1155
1156 def _device_handle_path() -> Path:
1157 """Where run_github_start persists the device_code/interval for run_github_poll.
1158
1159 Kept next to the .env in the config dir; falls back to the OS temp dir when
1160 no config dir is resolvable (clean/no-config mode).
1161 """
1162 try:
1163 from . import env as _env
1164
1165 if _env.CONFIG_FILE:
1166 return _env.CONFIG_FILE.parent / ".github-device-handle.json"
1167 except Exception:
1168 pass
1169 import tempfile
1170
1171 return Path(tempfile.gettempdir()) / "last30days-github-device-handle.json"
1172
1173
1174 def _start_device_flow() -> "Tuple[Dict[str, Any], Optional[Dict[str, Any]]]":
1175 """Submit the GitHub device flow and surface the code, without polling.
1176
1177 Returns ``(public_result, handle)``. ``handle`` is None for the
1178 already-registered and error cases (nothing to poll); otherwise it carries
1179 the private poll state (``device_code``/``interval``/``user_code``/
1180 ``clipboard_ok``) that never belongs in the public, stdout-printed result.
1181 Callers either persist the handle to a file (``run_github_start``, for a
1182 separate poll process) or hand it straight to ``run_github_poll`` in-memory
1183 (``run_full_device_auth``, so a failed file write can't strand the one-shot).
1184 """
1185 import sys
1186 import webbrowser
1187
1188 # Already-registered short-circuit: a saved key means no device dance. The
1189 # key is returned raw here and masked at the CLI boundary before print.
1190 existing = _existing_scrapecreators_key()
1191 if existing:
1192 return (
1193 {
1194 "status": "already_registered",
1195 "method": "existing",
1196 "api_key": existing,
1197 "persisted": True,
1198 },
1199 None,
1200 )
1201
1202 result = run_device_auth()
1203 if result is None:
1204 return ({"status": "error", "message": "Failed to start device auth flow"}, None)
1205
1206 device_code, user_code, verification_uri, interval = result
1207
1208 # Validate the code shape BEFORE copying, labeling, or emitting it. A
1209 # non-conforming user_code (e.g. a key-shaped value) is never surfaced as a
1210 # GitHub device code; we stop rather than instruct the user to paste garbage.
1211 if not _DEVICE_CODE_RE.match(user_code):
1212 logger.warning("Device auth returned a non-device-shaped user_code; aborting.")
1213 return (
1214 {
1215 "status": "error",
1216 "message": "ScrapeCreators returned an unexpected device-code format.",
1217 },
1218 None,
1219 )
1220
1221 # Structured stdout line for machine consumers.
1222 print(
1223 json.dumps(
1224 {
1225 "event": "device_code_ready",
1226 "user_code": user_code,
1227 "verification_uri": verification_uri,
1228 }
1229 ),
1230 flush=True,
1231 )
1232
1233 # Copy the code to the clipboard BEFORE opening the browser.
1234 clipboard_ok = False
1235 if sys.platform == "darwin":
1236 try:
1237 subprocess.run(["pbcopy"], input=user_code.encode(), check=True, timeout=5)
1238 clipboard_ok = True
1239 except Exception:
1240 pass # pbcopy unavailable or failed, fall through
1241
1242 # Print the code as a plain HUMAN line on stdout too, so a foreground caller
1243 # sees it in the returned output even without reading the JSON. The clipboard
1244 # claim is only made when pbcopy actually succeeded (else: type it).
1245 if clipboard_ok:
1246 print(
1247 f"Your GitHub code: {user_code} (already on your clipboard - just paste it, Cmd+V)",
1248 flush=True,
1249 )
1250 else:
1251 print(f"Your GitHub code: {user_code} (type it on the GitHub page)", flush=True)
1252
1253 # Human box on stderr for direct-terminal users.
1254 clipboard_hint = " (copied to clipboard)" if clipboard_ok else ""
1255 code_line = f" Your code: {user_code}{clipboard_hint}"
1256 action_line = " Paste it on the GitHub page that just opened"
1257 width = max(len(code_line), len(action_line)) + 2
1258 border = "-" * width
1259 print(f"\n+{border}+", file=sys.stderr)
1260 print(f"|{code_line.ljust(width)}|", file=sys.stderr)
1261 print(f"|{action_line.ljust(width)}|", file=sys.stderr)
1262 print(f"+{border}+", file=sys.stderr)
1263
1264 if verification_uri:
1265 try:
1266 webbrowser.open(verification_uri)
1267 except Exception:
1268 print(f"Open: {verification_uri}", file=sys.stderr)
1269
1270 public = {
1271 "status": "awaiting_authorization",
1272 "user_code": user_code,
1273 "verification_uri": verification_uri,
1274 "clipboard_ok": clipboard_ok,
1275 }
1276 handle = {
1277 "device_code": device_code,
1278 "interval": interval,
1279 "user_code": user_code,
1280 "clipboard_ok": clipboard_ok,
1281 }
1282 return (public, handle)
1283
1284
1285 def run_github_start() -> Dict[str, Any]:
1286 """Start the device flow and persist the poll handle for a later
1287 ``run_github_poll`` process. Returns the public result (never the private
1288 device_code). See ``_start_device_flow`` for the returned statuses."""
1289 public, handle = _start_device_flow()
1290 if handle is not None:
1291 # Persist the poll handle (0o600) so a separate --github-poll process can
1292 # resume it. Best-effort: the in-memory one-shot path does not depend on
1293 # this write succeeding.
1294 path = _device_handle_path()
1295 try:
1296 path.parent.mkdir(parents=True, exist_ok=True)
1297 path.write_text(json.dumps(handle), encoding="utf-8")
1298 os.chmod(path, 0o600)
1299 except Exception as exc:
1300 logger.warning("Could not persist device handle: %s", exc)
1301 return public
1302
1303
1304 def run_github_poll(timeout: int = 300, *, _handle: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1305 """Poll for authorization using the handle from start.
1306
1307 ``_handle`` (in-memory, from the one-shot) takes precedence over the
1308 persisted handle file. Returns success (with the fetched key), timeout, or
1309 the honest "Authorized but failed to fetch API key" branch. Deletes the
1310 persisted handle when the flow terminates.
1311 """
1312 import sys
1313
1314 if _handle is not None:
1315 data = _handle
1316 else:
1317 try:
1318 data = json.loads(_device_handle_path().read_text(encoding="utf-8"))
1319 except Exception:
1320 return {
1321 "status": "error",
1322 "message": "No pending GitHub device flow; run setup --github-start first.",
1323 }
1324
1325 device_code = data["device_code"]
1326 interval = int(data.get("interval", 5))
1327 user_code = data.get("user_code", "")
1328 # Read the real clipboard state so the polling reminder never falsely claims
1329 # the code is on the clipboard (non-macOS, or a failed pbcopy). Missing key
1330 # (older handle) defaults to False -- don't overstate.
1331 clipboard_ok = bool(data.get("clipboard_ok", False))
1332
1333 print("Waiting for authorization...", file=sys.stderr, flush=True)
1334 access_token = poll_device_auth(
1335 device_code, interval, timeout=timeout, user_code=user_code, clipboard_ok=clipboard_ok
1336 )
1337
1338 def _cleanup() -> None:
1339 try:
1340 _device_handle_path().unlink()
1341 except Exception:
1342 pass
1343
1344 if access_token is None:
1345 _cleanup()
1346 return {"status": "timeout", "user_code": user_code}
1347
1348 fetched = fetch_api_key(access_token)
1349 _cleanup()
1350 if fetched.get("ok") and fetched.get("api_key"):
1351 return {
1352 "status": "success",
1353 "method": "device",
1354 "api_key": fetched["api_key"],
1355 "user_code": user_code,
1356 }
1357
1358 # Discriminate failure modes so SKILL.md does not misdiagnose a 5xx as
1359 # "GitHub already linked" (#882). Keep the historical message only for the
1360 # 2xx-without-api_key / already-linked case.
1361 reason = fetched.get("reason") or "request_failed"
1362 if reason == "no_api_key":
1363 return {
1364 "status": "error",
1365 "message": "Authorized but failed to fetch API key",
1366 "reason": "no_api_key",
1367 }
1368 if reason == "upstream_error":
1369 http_status = fetched.get("http_status")
1370 return {
1371 "status": "error",
1372 "message": f"Authorized but ScrapeCreators profile failed (HTTP {http_status})",
1373 "reason": "upstream_error",
1374 "http_status": http_status,
1375 "detail": fetched.get("detail"),
1376 }
1377 if reason == "http_error":
1378 http_status = fetched.get("http_status")
1379 return {
1380 "status": "error",
1381 "message": f"Authorized but failed to fetch API key (HTTP {http_status})",
1382 "reason": "http_error",
1383 "http_status": http_status,
1384 "detail": fetched.get("detail"),
1385 }
1386 return {
1387 "status": "error",
1388 "message": "Authorized but failed to fetch API key",
1389 "reason": reason,
1390 "detail": fetched.get("detail"),
1391 }
1392
1393
1394 def run_full_device_auth(timeout: int = 300) -> Dict[str, Any]:
1395 """Back-compat one-shot: start the device flow, then poll to completion.
1396
1397 Passes the poll handle to ``run_github_poll`` IN MEMORY, so a failed handle-
1398 file write can't strand the one-shot. Kept so callers of ``setup --github`` /
1399 ``--device-auth`` still work; the model-driven wizard uses the two-command
1400 split (start then poll) instead.
1401 """
1402 public, handle = _start_device_flow()
1403 if handle is None:
1404 return public # already_registered or error
1405 return run_github_poll(timeout=timeout, _handle=handle)
1406
1407
1408 # ---------------------------------------------------------------------------
1409 # Unified GitHub auth
1410 # ---------------------------------------------------------------------------
1411
1412
1413 def run_github_auth(timeout: int = 300) -> Dict[str, Any]:
1414 """Run the --github setup path via device auth (one-shot, back-compat).
1415
1416 The existing-key short-circuit now lives in run_github_start; this delegates
1417 to the start+poll chain. This path must not read or forward local GitHub CLI
1418 tokens.
1419 """
1420 return run_full_device_auth(timeout=timeout)
1421
1421 lines PYTHON