fix(bird_x): pass .env credentials to Node subprocesses for WSL2/headless auth

* chore: fix YAML error in argument-hint

* add codex auth support to responses API

* Use gpt-5.1-codex-mini as default model for Codex auth

Add CODEX_FALLBACK_MODELS chain (gpt-5.1-codex-mini → gpt-5.2) for
Codex endpoint which doesn't support standard OpenAI models like
gpt-4o-mini. Adds model fallback retry on 400 errors in the Codex
search path. Also adds test_codex_auth.py with 22 unit tests covering
JWT decoding, auth resolution, SSE parsing, and payload building.

* Pass .env credentials to Bird Node subprocesses for X auth

On platforms without browser cookie access (e.g. WSL2), Bird's
vendored Node.js module cannot read AUTH_TOKEN/CT0 from Firefox
or Chrome cookie stores. The .env config file already supports
these values, but they were only loaded into the Python config
dict — never exported to the environment of Node subprocesses.

- Add AUTH_TOKEN/CT0 to env.py config key loading
- Add set_credentials()/\_subprocess_env() to bird_x.py to inject
  credentials into the env dict passed to subprocess.run/Popen
- Call set_credentials() in main() before Bird auth detection

---------

Co-authored-by: Justin Williams <jblwilliams@gmail.com>
This commit is contained in:
Ilia Alshanetsky
2026-03-03 02:24:59 -05:00
committed by GitHub
parent 1ed990a081
commit d7bff81757
12 changed files with 582 additions and 37 deletions
+4 -2
View File
@@ -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.
+2
View File
@@ -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 \
+2 -2
View File
@@ -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
+7 -1
View File
@@ -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
+20
View File
@@ -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)
+151 -7
View File
@@ -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
+11 -4
View File
@@ -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.
+17 -7
View File
@@ -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(
+159 -6
View File
@@ -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):
+8 -5
View File
@@ -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,
+2 -3
View File
@@ -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",
}
+199
View File
@@ -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()