diff --git a/README.md b/README.md index 45026bf..06df602 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,17 @@ # Clone the repo git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days -# Add your API keys +# Add your API keys (optional if signed in to Codex) mkdir -p ~/.config/last30days cat > ~/.config/last30days/.env << 'EOF' -OPENAI_API_KEY=sk-... +OPENAI_API_KEY=sk-... # optional if using `codex login` XAI_API_KEY=xai-... # optional - cookie auth is default for X search EOF chmod 600 ~/.config/last30days/.env ``` +If you're signed in to Codex (`codex login`), the skill will use your Codex credentials for the OpenAI Responses API and you can omit `OPENAI_API_KEY`. If you're not signed in, run `codex login` first. + ### X Search Authentication X search reads your existing browser cookies - no API keys or login commands needed. diff --git a/SKILL.md b/SKILL.md index e6863d8..909dc6c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -146,6 +146,8 @@ Generated: {date} | Sources: Reddit, X, YouTube, HN, Polymarket, Web **CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.** +**IMPORTANT: The script handles API key/Codex auth detection automatically.** Run it and check the output to determine mode. + ```bash # Find skill root — works in repo checkout, Claude Code, or Codex install for dir in \ diff --git a/SPEC.md b/SPEC.md index e2cd212..8ee804b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2,7 +2,7 @@ ## Overview -`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet. +`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet. OpenAI auth can come from `OPENAI_API_KEY` or Codex login credentials. The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability. @@ -10,7 +10,7 @@ The skill operates in three modes depending on available API keys: **reddit-only The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`: -- **env.py**: Load and validate API keys from `~/.config/last30days/.env` +- **env.py**: Load API keys from `~/.config/last30days/.env` and Codex auth from `~/.codex/auth.json` - **dates.py**: Date range calculation and confidence scoring - **cache.py**: 24-hour TTL caching keyed by topic + date range - **http.py**: stdlib-only HTTP client with retry logic diff --git a/scripts/last30days.py b/scripts/last30days.py index 096a38f..2b02d3f 100644 --- a/scripts/last30days.py +++ b/scripts/last30days.py @@ -111,7 +111,6 @@ from lib import ( schema, score, ui, - websearch, xai_x, youtube_yt, ) @@ -154,6 +153,8 @@ def _search_reddit( from_date, to_date, depth=depth, + auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"), + account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"), ) except http.HTTPError as e: raw_openai = {"error": str(e)} @@ -176,6 +177,8 @@ def _search_reddit( core, from_date, to_date, depth=depth, + auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"), + account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"), ) retry_items = openai_reddit.parse_reddit_response(retry_raw) # Add items not already found (by URL) @@ -1032,6 +1035,9 @@ def main(): # Load config config = env.get_config() + # Inject .env credentials into Bird module before auth check + bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0')) + # Auto-detect Bird (no prompts - just use it if available) x_source_status = env.get_x_source_status(config) x_source = x_source_status["source"] # 'bird', 'xai', or None diff --git a/scripts/lib/bird_x.py b/scripts/lib/bird_x.py index e3c318f..be01d46 100644 --- a/scripts/lib/bird_x.py +++ b/scripts/lib/bird_x.py @@ -24,6 +24,24 @@ DEPTH_CONFIG = { "deep": 60, } +# Module-level credentials injected from .env config +_credentials: Dict[str, str] = {} + + +def set_credentials(auth_token: Optional[str], ct0: Optional[str]): + """Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them.""" + if auth_token: + _credentials['AUTH_TOKEN'] = auth_token + if ct0: + _credentials['CT0'] = ct0 + + +def _subprocess_env() -> Dict[str, str]: + """Build env dict for Node subprocesses, merging injected credentials.""" + env = os.environ.copy() + env.update(_credentials) + return env + def _log(msg: str): """Log to stderr.""" @@ -112,6 +130,7 @@ def is_bird_authenticated() -> Optional[str]: capture_output=True, text=True, timeout=15, + env=_subprocess_env(), ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip().split('\n')[0] @@ -187,6 +206,7 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]: stderr=subprocess.PIPE, text=True, preexec_fn=preexec, + env=_subprocess_env(), ) # Register for cleanup tracking (if available) diff --git a/scripts/lib/env.py b/scripts/lib/env.py index c04179a..9981fb2 100644 --- a/scripts/lib/env.py +++ b/scripts/lib/env.py @@ -1,9 +1,12 @@ """Environment and API key management for last30days skill.""" +import base64 import json import os +import time +from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Literal # Allow override via environment variable for testing # Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode @@ -20,6 +23,29 @@ else: CONFIG_DIR = Path.home() / ".config" / "last30days" CONFIG_FILE = CONFIG_DIR / ".env" +CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json"))) + +AuthSource = Literal["api_key", "codex", "none"] +AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"] + +AUTH_SOURCE_API_KEY: AuthSource = "api_key" +AUTH_SOURCE_CODEX: AuthSource = "codex" +AUTH_SOURCE_NONE: AuthSource = "none" + +AUTH_STATUS_OK: AuthStatus = "ok" +AUTH_STATUS_MISSING: AuthStatus = "missing" +AUTH_STATUS_EXPIRED: AuthStatus = "expired" +AUTH_STATUS_MISSING_ACCOUNT_ID: AuthStatus = "missing_account_id" + + +@dataclass(frozen=True) +class OpenAIAuth: + token: Optional[str] + source: AuthSource + status: AuthStatus + account_id: Optional[str] + codex_auth_file: str + def load_env_file(path: Path) -> Dict[str, str]: """Load environment variables from a file.""" @@ -44,14 +70,131 @@ def load_env_file(path: Path) -> Dict[str, str]: return env +def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]: + """Decode JWT payload without verification.""" + try: + parts = token.split(".") + if len(parts) < 2: + return None + payload_b64 = parts[1] + pad = "=" * (-len(payload_b64) % 4) + decoded = base64.urlsafe_b64decode(payload_b64 + pad) + return json.loads(decoded.decode("utf-8")) + except Exception: + return None + + +def _token_expired(token: str, leeway_seconds: int = 60) -> bool: + """Check if JWT token is expired.""" + payload = _decode_jwt_payload(token) + if not payload: + return False + exp = payload.get("exp") + if not exp: + return False + return exp <= (time.time() + leeway_seconds) + + +def extract_chatgpt_account_id(access_token: str) -> Optional[str]: + """Extract chatgpt_account_id from JWT token.""" + payload = _decode_jwt_payload(access_token) + if not payload: + return None + auth_claim = payload.get("https://api.openai.com/auth", {}) + if isinstance(auth_claim, dict): + return auth_claim.get("chatgpt_account_id") + return None + + +def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> Dict[str, Any]: + """Load Codex auth JSON.""" + if not path.exists(): + return {} + try: + with open(path, "r") as f: + return json.load(f) + except Exception: + return {} + + +def get_codex_access_token() -> tuple[Optional[str], str]: + """Get Codex access token from auth.json. + + Returns: + (token, status) where status is 'ok', 'missing', or 'expired' + """ + auth = load_codex_auth() + token = None + if isinstance(auth, dict): + tokens = auth.get("tokens") or {} + if isinstance(tokens, dict): + token = tokens.get("access_token") + if not token: + token = auth.get("access_token") + if not token: + return None, AUTH_STATUS_MISSING + if _token_expired(token): + return None, AUTH_STATUS_EXPIRED + return token, AUTH_STATUS_OK + + +def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth: + """Resolve OpenAI auth from API key or Codex login.""" + api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY') + if api_key: + return OpenAIAuth( + token=api_key, + source=AUTH_SOURCE_API_KEY, + status=AUTH_STATUS_OK, + account_id=None, + codex_auth_file=str(CODEX_AUTH_FILE), + ) + + codex_token, codex_status = get_codex_access_token() + if codex_token: + account_id = extract_chatgpt_account_id(codex_token) + if account_id: + return OpenAIAuth( + token=codex_token, + source=AUTH_SOURCE_CODEX, + status=AUTH_STATUS_OK, + account_id=account_id, + codex_auth_file=str(CODEX_AUTH_FILE), + ) + return OpenAIAuth( + token=None, + source=AUTH_SOURCE_CODEX, + status=AUTH_STATUS_MISSING_ACCOUNT_ID, + account_id=None, + codex_auth_file=str(CODEX_AUTH_FILE), + ) + + return OpenAIAuth( + token=None, + source=AUTH_SOURCE_NONE, + status=codex_status, + account_id=None, + codex_auth_file=str(CODEX_AUTH_FILE), + ) + + def get_config() -> Dict[str, Any]: """Load configuration from ~/.config/last30days/.env and environment.""" # Load from config file first (if configured) file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {} - # Build config: process.env > .env file + openai_auth = get_openai_auth(file_env) + + # Build config: Codex/OpenAI auth + process.env > .env file + config = { + 'OPENAI_API_KEY': openai_auth.token, + 'OPENAI_AUTH_SOURCE': openai_auth.source, + 'OPENAI_AUTH_STATUS': openai_auth.status, + 'OPENAI_CHATGPT_ACCOUNT_ID': openai_auth.account_id, + 'CODEX_AUTH_FILE': openai_auth.codex_auth_file, + } + keys = [ - ('OPENAI_API_KEY', None), ('XAI_API_KEY', None), ('OPENROUTER_API_KEY', None), ('PARALLEL_API_KEY', None), @@ -60,9 +203,10 @@ def get_config() -> Dict[str, Any]: ('OPENAI_MODEL_PIN', None), ('XAI_MODEL_POLICY', 'latest'), ('XAI_MODEL_PIN', None), + ('AUTH_TOKEN', None), + ('CT0', None), ] - config = {} for key, default in keys: config[key] = os.environ.get(key) or file_env.get(key, default) @@ -79,7 +223,7 @@ def get_available_sources(config: Dict[str, Any]) -> str: Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none' """ - has_openai = bool(config.get('OPENAI_API_KEY')) + has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK has_xai = bool(config.get('XAI_API_KEY')) has_web = has_web_search_keys(config) @@ -121,7 +265,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str: Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none' """ - has_openai = bool(config.get('OPENAI_API_KEY')) + has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK has_xai = bool(config.get('XAI_API_KEY')) has_web = has_web_search_keys(config) @@ -170,7 +314,7 @@ def validate_sources(requested: str, available: str, include_web: bool = False) elif requested == 'web': return 'web', None else: - return 'web', f"Only web search keys configured. Add OPENAI_API_KEY for Reddit, XAI_API_KEY for X." + return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X." if requested == 'auto': # Add web to sources if include_web is set diff --git a/scripts/lib/http.py b/scripts/lib/http.py index 9894c6e..a6f4bb7 100644 --- a/scripts/lib/http.py +++ b/scripts/lib/http.py @@ -38,6 +38,7 @@ def request( json_data: Optional[Dict[str, Any]] = None, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES, + raw: bool = False, ) -> Dict[str, Any]: """Make an HTTP request and return JSON response. @@ -50,7 +51,7 @@ def request( retries: Number of retries on failure Returns: - Parsed JSON response + Parsed JSON response (or raw text if raw=True) Raises: HTTPError: On request failure @@ -66,8 +67,6 @@ def request( req = urllib.request.Request(url, data=data, headers=headers, method=method) log(f"{method} {url}") - if json_data: - log(f"Payload keys: {list(json_data.keys())}") last_error = None for attempt in range(retries): @@ -75,6 +74,8 @@ def request( with urllib.request.urlopen(req, timeout=timeout) as response: body = response.read().decode('utf-8') log(f"Response: {response.status} ({len(body)} bytes)") + if raw: + return body return json.loads(body) if body else {} except urllib.error.HTTPError as e: body = None @@ -84,7 +85,8 @@ def request( pass log(f"HTTP Error {e.code}: {e.reason}") if body: - log(f"Error body: {body[:500]}") + snippet = " ".join(body.split()) + log(f"Error body: {snippet[:200]}") last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body) # Don't retry client errors (4xx) except rate limits @@ -137,6 +139,11 @@ def post(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] return request("POST", url, headers=headers, json_data=json_data, **kwargs) +def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, **kwargs) -> str: + """Make a POST request with JSON body and return raw text.""" + return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs) + + def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]: """Fetch Reddit thread JSON. diff --git a/scripts/lib/models.py b/scripts/lib/models.py index 29e1223..13ed3bb 100644 --- a/scripts/lib/models.py +++ b/scripts/lib/models.py @@ -3,11 +3,12 @@ import re from typing import Dict, List, Optional, Tuple -from . import cache, http +from . import cache, http, env # OpenAI API OPENAI_MODELS_URL = "https://api.openai.com/v1/models" OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"] +CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"] # xAI API - Agent Tools API requires grok-4 family XAI_MODELS_URL = "https://api.x.ai/v1/models" @@ -157,12 +158,21 @@ def get_models( result = {"openai": None, "xai": None} if config.get("OPENAI_API_KEY"): - result["openai"] = select_openai_model( - config["OPENAI_API_KEY"], - config.get("OPENAI_MODEL_POLICY", "auto"), - config.get("OPENAI_MODEL_PIN"), - mock_openai_models, - ) + if config.get("OPENAI_AUTH_SOURCE") == env.AUTH_SOURCE_CODEX: + # Codex auth doesn't use the OpenAI models list endpoint + policy = config.get("OPENAI_MODEL_POLICY", "auto") + pin = config.get("OPENAI_MODEL_PIN") + if policy == "pinned" and pin: + result["openai"] = pin + else: + result["openai"] = CODEX_FALLBACK_MODELS[0] + else: + result["openai"] = select_openai_model( + config["OPENAI_API_KEY"], + config.get("OPENAI_MODEL_POLICY", "auto"), + config.get("OPENAI_MODEL_PIN"), + mock_openai_models, + ) if config.get("XAI_API_KEY"): result["xai"] = select_xai_model( diff --git a/scripts/lib/openai_reddit.py b/scripts/lib/openai_reddit.py index 2c6b838..6a8f361 100644 --- a/scripts/lib/openai_reddit.py +++ b/scripts/lib/openai_reddit.py @@ -5,7 +5,7 @@ import re import sys from typing import Any, Dict, List, Optional -from . import http +from . import http, env # Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5) # Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it @@ -42,6 +42,93 @@ def _is_model_access_error(error: http.HTTPError) -> bool: OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses" +CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses" +CODEX_INSTRUCTIONS = ( + "You are a research assistant for a skill that summarizes what people are " + "discussing in the last 30 days. Your goal is to find relevant Reddit threads " + "about the topic and return ONLY the required JSON. Be inclusive (return more " + "rather than fewer), but avoid irrelevant results. Prefer threads with discussion " + "and comments. If you can infer a date, include it; otherwise use null. " + "Do not include developers.reddit.com or business.reddit.com." +) + + +def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]: + """Parse a single SSE chunk into a JSON object.""" + lines = chunk.split("\n") + data_lines = [] + + for line in lines: + if line.startswith("data:"): + data_lines.append(line[5:].strip()) + + if not data_lines: + return None + + data = "\n".join(data_lines).strip() + if not data or data == "[DONE]": + return None + + try: + return json.loads(data) + except json.JSONDecodeError: + return None + + +def _parse_sse_stream_raw(raw: str) -> List[Dict[str, Any]]: + """Parse SSE stream from raw text and return JSON events.""" + events: List[Dict[str, Any]] = [] + buffer = "" + for chunk in raw.splitlines(keepends=True): + buffer += chunk + while "\n\n" in buffer: + event_chunk, buffer = buffer.split("\n\n", 1) + event = _parse_sse_chunk(event_chunk) + if event is not None: + events.append(event) + if buffer.strip(): + event = _parse_sse_chunk(buffer) + if event is not None: + events.append(event) + return events + + +def _parse_codex_stream(raw: str) -> Dict[str, Any]: + """Parse SSE stream from Codex responses into a response-like dict.""" + events = _parse_sse_stream_raw(raw) + + # Prefer explicit completed response payload if present + for evt in reversed(events): + if isinstance(evt, dict): + if evt.get("type") == "response.completed" and isinstance(evt.get("response"), dict): + return evt["response"] + if isinstance(evt.get("response"), dict): + return evt["response"] + + # Fallback: reconstruct output text from deltas + output_text = "" + for evt in events: + if not isinstance(evt, dict): + continue + delta = evt.get("delta") + if isinstance(delta, str): + output_text += delta + continue + text = evt.get("text") + if isinstance(text, str): + output_text += text + + if output_text: + return { + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": output_text}], + } + ] + } + + return {} # Depth configurations: (min, max) threads to request # Request MORE than needed since many get filtered by date @@ -116,6 +203,35 @@ def _build_subreddit_query(topic: str) -> str: return f"r/{sub_name} site:reddit.com" +def _build_payload(model: str, instructions_text: str, input_text: str, auth_source: str) -> Dict[str, Any]: + """Build responses payload for OpenAI or Codex endpoints.""" + payload = { + "model": model, + "store": False, + "tools": [ + { + "type": "web_search", + "filters": { + "allowed_domains": ["reddit.com"] + } + } + ], + "include": ["web_search_call.action.sources"], + "instructions": instructions_text, + "input": input_text, + } + if auth_source == env.AUTH_SOURCE_CODEX: + payload["input"] = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": input_text}], + } + ] + payload["stream"] = True + return payload + + def search_reddit( api_key: str, model: str, @@ -123,6 +239,8 @@ def search_reddit( from_date: str, to_date: str, depth: str = "default", + auth_source: str = "api_key", + account_id: Optional[str] = None, mock_response: Optional[Dict] = None, _retry: bool = False, ) -> Dict[str, Any]: @@ -145,10 +263,23 @@ def search_reddit( min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } + if auth_source == env.AUTH_SOURCE_CODEX: + if not account_id: + raise ValueError("Missing chatgpt_account_id for Codex auth") + headers = { + "Authorization": f"Bearer {api_key}", + "chatgpt-account-id": account_id, + "OpenAI-Beta": "responses=experimental", + "originator": "pi", + "Content-Type": "application/json", + } + url = CODEX_RESPONSES_URL + else: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + url = OPENAI_RESPONSES_URL # Adjust timeout based on depth (generous for OpenAI web_search which can be slow) timeout = 90 if depth == "quick" else 120 if depth == "default" else 180 @@ -166,6 +297,28 @@ def search_reddit( max_items=max_items, ) + if auth_source == env.AUTH_SOURCE_CODEX: + # Codex auth: try model with fallback chain + from . import models as models_mod + codex_models_to_try = [model] + [m for m in models_mod.CODEX_FALLBACK_MODELS if m != model] + instructions_text = CODEX_INSTRUCTIONS + "\n\n" + input_text + last_error = None + for current_model in codex_models_to_try: + try: + payload = _build_payload(current_model, instructions_text, topic, auth_source) + raw = http.post_raw(url, payload, headers=headers, timeout=timeout) + return _parse_codex_stream(raw or "") + except http.HTTPError as e: + last_error = e + if e.status_code == 400: + _log_info(f"Model {current_model} not supported on Codex, trying fallback...") + continue + raise + if last_error: + raise last_error + raise http.HTTPError("No Codex-compatible models available") + + # Standard API key auth: try model fallback chain last_error = None for current_model in models_to_try: payload = { @@ -183,7 +336,7 @@ def search_reddit( } try: - return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout) + return http.post(url, payload, headers=headers, timeout=timeout) except http.HTTPError as e: last_error = e if _is_model_access_error(e): diff --git a/scripts/lib/render.py b/scripts/lib/render.py index 3bb6be2..d13770d 100644 --- a/scripts/lib/render.py +++ b/scripts/lib/render.py @@ -4,7 +4,7 @@ import json import os import tempfile from pathlib import Path -from typing import List, Optional +from typing import Optional from . import schema @@ -101,10 +101,11 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = " lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news") lines.append("") lines.append("---") - lines.append("**⚔ Want better results?** Add API keys to unlock Reddit & X data:") - lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments") + lines.append("**⚔ Want better results?** Add API keys or sign in to Codex to unlock Reddit & X data:") + lines.append("- `OPENAI_API_KEY` or `codex login` → Reddit threads with real upvotes & comments") lines.append("- `XAI_API_KEY` → X posts with real likes & reposts") lines.append("- Edit `~/.config/last30days/.env` to add keys") + lines.append("- If already signed in but still seeing this, re-run `codex login`") lines.append("---") lines.append("") @@ -129,7 +130,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = " lines.append("*šŸ’” Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*") lines.append("") elif report.mode == "x-only" and missing_keys == "reddit": - lines.append("*šŸ’” Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*") + lines.append("*šŸ’” Tip: Add OPENAI_API_KEY or run `codex login` for Reddit data and better triangulation. If already signed in, re-run `codex login`.*") lines.append("") # Reddit items @@ -168,7 +169,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = " # Top comment insights if item.comment_insights: - lines.append(f" Insights:") + lines.append(" Insights:") for insight in item.comment_insights[:3]: lines.append(f" - {insight}") @@ -630,6 +631,8 @@ def render_full_report(report: schema.Report) -> str: return "\n".join(lines) + + def write_outputs( report: schema.Report, raw_openai: Optional[dict] = None, diff --git a/scripts/lib/ui.py b/scripts/lib/ui.py index ee88f9f..80631a8 100644 --- a/scripts/lib/ui.py +++ b/scripts/lib/ui.py @@ -1,6 +1,5 @@ """Terminal UI utilities for last30days skill.""" -import os import sys import time import threading @@ -117,7 +116,7 @@ I just researched that for you. Here's what I've got right now: {status_line} -You can unlock more sources with API keys — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is. +You can unlock more sources with API keys or by signing in to Codex — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is. Some examples of what you can do: - "last30 what are people saying about Figma" @@ -131,7 +130,7 @@ Just start with "last30" and talk to me like normal. # Shorter promo for single missing key PROMO_SINGLE_KEY = { - "reddit": "\nšŸ’” You can unlock Reddit with an OpenAI API key — just ask me how.\n", + "reddit": "\nšŸ’” You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n", "x": "\nšŸ’” You can unlock X with an xAI API key — just ask me how.\n", } diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py new file mode 100644 index 0000000..5634de0 --- /dev/null +++ b/tests/test_codex_auth.py @@ -0,0 +1,199 @@ +"""Tests for Codex auth integration (env.py + openai_reddit.py).""" + +import base64 +import json +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +# Add scripts directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from lib import env, openai_reddit + + +def _make_jwt(payload: dict) -> str: + """Build a fake JWT with the given payload (no signature verification).""" + header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=") + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=") + return f"{header.decode()}.{body.decode()}.fakesig" + + +class TestDecodeJwtPayload(unittest.TestCase): + + def test_valid_jwt(self): + token = _make_jwt({"sub": "user123", "exp": 9999999999}) + result = env._decode_jwt_payload(token) + self.assertEqual(result["sub"], "user123") + + def test_invalid_jwt(self): + self.assertIsNone(env._decode_jwt_payload("not-a-jwt")) + + def test_empty_string(self): + self.assertIsNone(env._decode_jwt_payload("")) + + +class TestTokenExpired(unittest.TestCase): + + def test_not_expired(self): + token = _make_jwt({"exp": int(time.time()) + 3600}) + self.assertFalse(env._token_expired(token)) + + def test_expired(self): + token = _make_jwt({"exp": int(time.time()) - 100}) + self.assertTrue(env._token_expired(token)) + + def test_no_exp_claim(self): + token = _make_jwt({"sub": "user"}) + self.assertFalse(env._token_expired(token)) + + +class TestExtractChatgptAccountId(unittest.TestCase): + + def test_extracts_account_id(self): + token = _make_jwt({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct_abc123" + } + }) + self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123") + + def test_missing_auth_claim(self): + token = _make_jwt({"sub": "user"}) + self.assertIsNone(env.extract_chatgpt_account_id(token)) + + def test_missing_account_id_in_claim(self): + token = _make_jwt({ + "https://api.openai.com/auth": {"other_field": "value"} + }) + self.assertIsNone(env.extract_chatgpt_account_id(token)) + + +class TestGetOpenaiAuth(unittest.TestCase): + + def test_api_key_takes_priority(self): + """OPENAI_API_KEY in env file should be preferred over Codex.""" + file_env = {"OPENAI_API_KEY": "sk-test123"} + auth = env.get_openai_auth(file_env) + self.assertEqual(auth.source, "api_key") + self.assertEqual(auth.status, "ok") + self.assertEqual(auth.token, "sk-test123") + self.assertIsNone(auth.account_id) + + @patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False) + def test_env_var_takes_priority(self): + """OPENAI_API_KEY env var should be preferred over file.""" + file_env = {} + auth = env.get_openai_auth(file_env) + self.assertEqual(auth.source, "api_key") + self.assertEqual(auth.token, "sk-from-env") + + def test_no_keys_returns_none_source(self): + """No API key and no Codex auth → source=none.""" + fake_path = Path("/tmp/nonexistent_codex_auth_test.json") + with patch.object(env, 'CODEX_AUTH_FILE', fake_path): + # Also patch get_codex_access_token to avoid reading real auth file + with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")): + environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"} + with patch.dict(os.environ, environ_copy, clear=True): + auth = env.get_openai_auth({}) + self.assertEqual(auth.source, "none") + self.assertIsNone(auth.token) + + +class TestLoadCodexAuth(unittest.TestCase): + + def test_nonexistent_file(self): + result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json")) + self.assertEqual(result, {}) + + def test_valid_json(self): + import tempfile + data = {"tokens": {"access_token": "tok123"}} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + result = env.load_codex_auth(Path(f.name)) + os.unlink(f.name) + self.assertEqual(result["tokens"]["access_token"], "tok123") + + +class TestGetAvailableSourcesWithAuth(unittest.TestCase): + + def test_codex_auth_ok_counts_as_openai(self): + config = { + "OPENAI_API_KEY": "codex-token", + "OPENAI_AUTH_STATUS": "ok", + "XAI_API_KEY": None, + } + result = env.get_available_sources(config) + self.assertIn("reddit", result) + + def test_codex_auth_expired_not_counted(self): + config = { + "OPENAI_API_KEY": None, + "OPENAI_AUTH_STATUS": "expired", + "XAI_API_KEY": None, + } + result = env.get_available_sources(config) + self.assertEqual(result, "web") + + +class TestParseCodexStream(unittest.TestCase): + + def test_response_completed_event(self): + """Should extract response from response.completed SSE event.""" + sse = ( + 'data: {"type":"response.created","response":{"id":"r1"}}\n\n' + 'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n' + ) + result = openai_reddit._parse_codex_stream(sse) + self.assertIn("output", result) + + def test_delta_fallback(self): + """Should reconstruct text from delta events.""" + sse = ( + 'data: {"delta":"hel"}\n\n' + 'data: {"delta":"lo"}\n\n' + ) + result = openai_reddit._parse_codex_stream(sse) + self.assertIn("output", result) + text = result["output"][0]["content"][0]["text"] + self.assertEqual(text, "hello") + + def test_empty_stream(self): + result = openai_reddit._parse_codex_stream("") + self.assertEqual(result, {}) + + +class TestBuildPayload(unittest.TestCase): + + def test_api_key_payload(self): + payload = openai_reddit._build_payload( + "gpt-4o", "instructions", "input text", "api_key" + ) + self.assertEqual(payload["model"], "gpt-4o") + self.assertEqual(payload["input"], "input text") + self.assertNotIn("stream", payload) + + def test_codex_payload_has_stream(self): + payload = openai_reddit._build_payload( + "gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX + ) + self.assertTrue(payload["stream"]) + # Input should be structured message format for Codex + self.assertIsInstance(payload["input"], list) + self.assertEqual(payload["input"][0]["role"], "user") + + def test_codex_payload_has_store_false(self): + payload = openai_reddit._build_payload( + "gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX + ) + self.assertFalse(payload["store"]) + + +if __name__ == "__main__": + unittest.main()