
Rdt Cli
- 193 installs
- 484 repo stars
- Updated March 21, 2026
- jackwener/rdt-cli
Operate Reddit from the terminal for research, posting, and workflow automation when building CLI-first or agent-driven social tooling.
About
Covers building and using the rdt-cli Reddit command-line tool for terminal-driven browsing, posting, and automation workflows suited to developers and agent pipelines.
- Terminal-first Reddit commands
- Scriptable social workflows
- Agent-friendly CLI surface
- Research and posting automation
- Composable command design
Rdt Cli by the numbers
- 193 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #205 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/rdt-cli --skill rdt-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 193 |
|---|---|
| repo stars | ★ 484 |
| Last updated | March 21, 2026 |
| Repository | jackwener/rdt-cli ↗ |
What it does
Operate Reddit from the terminal for research, posting, and workflow automation when building CLI-first or agent-driven social tooling.
Files
rdt-cli — Reddit CLI Tool
Binary: rdt Credentials: browser cookies (auto-extracted via browser-cookie3)
Setup
# Install (requires Python 3.10+)
uv tool install rdt-cli
# Or: pip install rdt-cli
# Upgrade
uv tool upgrade rdt-cliAuthentication
IMPORTANT FOR AGENTS: Before executing ANY rdt command that requires auth, check if credentials exist.
Step 0: Check if already authenticated
rdt status --json 2>/dev/null | jq -r '.data.authenticated' | grep -q true && echo "AUTH_OK" || echo "AUTH_NEEDED"If AUTH_OK, skip to Command Reference. If AUTH_NEEDED, proceed to Step 1.
Step 1: Guide user to authenticate
Ensure user is logged into reddit.com in a supported browser (Chrome, Firefox, Edge, Brave, Arc, Chromium, Opera, Vivaldi, Safari, LibreWolf). Then:
rdt loginVerify with:
rdt status
rdt whoami --json | jq '.data.name // .data._session.username'Step 2: Handle common auth issues
| Symptom | Agent action |
|---|---|
No Reddit cookies found | Guide user to login to reddit.com in browser |
Session expired | Run rdt logout && rdt login |
database is locked | Close browser, then retry |
Agent Defaults
All machine-readable output uses the envelope documented in SCHEMA.md. Payloads live under .data.
- Non-TTY stdout → auto YAML
--json/--yaml→ explicit format--compact→ fewer fields (agent token-efficient)--output file.json→ save structured output to file- Rich output → stderr (safe for pipes:
rdt search X --json | jq .data) - Most read commands work without auth (public Reddit JSON API)
- Write actions (upvote, save, subscribe) require auth + built-in 1.5-4s delay
Command Reference
Browsing
| Command | Description | Example |
|---|---|---|
rdt feed | Browse home feed (requires login) | rdt feed -n 10 --json |
rdt feed --subs-only | Subscriptions-only feed (no algorithm, sorted by time) | rdt feed --subs-only -n 5 --json |
rdt popular | Browse /r/popular | rdt popular -n 5 --json |
rdt all | Browse /r/all | rdt all -n 10 --compact --json |
rdt sub <name> | Browse a subreddit | rdt sub python -s top -t week |
rdt sub-info <name> | View subreddit info | rdt sub-info rust --json |
rdt user <name> | View user profile | rdt user spez --json |
rdt user-posts <name> | View user's posts | rdt user-posts spez -n 5 --json |
rdt user-comments <name> | View user's comments | rdt user-comments spez -n 5 --json |
rdt saved | View your saved items | rdt saved -n 10 --json |
rdt upvoted | View your upvoted posts | rdt upvoted -n 10 --json |
rdt open <id_or_index> | Open post in browser | rdt open 3 |
Reading
| Command | Description | Example |
|---|---|---|
rdt read <post_id> | Read a post + comments | rdt read 1abc123 --json |
rdt read <post_id> --expand-more | Expand top-level more comments | rdt read 1abc123 --expand-more --json |
rdt show <index> | Read by short-index | rdt show 3 |
rdt show <index> --expand-more | Expand more comments from cached result | rdt show 3 --expand-more --json |
rdt whoami | View your profile (karma, age) | rdt whoami --json |
Search & Export
| Command | Description | Example |
|---|---|---|
rdt search <query> | Search posts | rdt search "python async" -s top -t year |
rdt search <query> -r <sub> | Search in subreddit | rdt search "error" -r rust --json |
rdt search <query> -o f.json | Search + save to file | rdt search "ML" -n 50 -o results.json |
rdt export <query> | Export to CSV/JSON | rdt export "ML" -n 50 -o results.csv |
Interactions (require auth)
| Command | Description | Example |
|---|---|---|
rdt upvote <id_or_index> | Upvote | rdt upvote 3 |
rdt upvote <id> --down | Downvote | rdt upvote 3 --down |
rdt upvote <id> --undo | Remove vote | rdt upvote 3 --undo |
rdt save <id_or_index> | Save post | rdt save 3 |
rdt save <id> --undo | Unsave | rdt save 3 --undo |
rdt subscribe <sub> | Subscribe | rdt subscribe python |
rdt subscribe <sub> --undo | Unsubscribe | rdt subscribe python --undo |
rdt comment <id> <text> | Post a comment | rdt comment 3 "Great post!" |
Account
| Command | Description |
|---|---|
rdt login | Extract cookies from browser |
rdt logout | Clear cached cookies |
rdt status | Check authentication status |
rdt whoami | View detailed profile info |
Listing Options
All listing commands (feed, popular, all, sub, user-posts, user-comments, saved, upvoted, search) support:
| Flag | Description |
|---|---|
--json | JSON output (with SCHEMA envelope) |
--yaml | YAML output (with SCHEMA envelope) |
-o, --output FILE | Save structured output to file |
--full-text | Show full title without truncation |
-c, --compact | Agent-friendly compact output (fewer fields) |
Feed-specific Options
| Flag | Description |
|---|---|
--subs-only | Show only posts from subscribed subreddits (sorted by time, no algorithm) |
--max-subs N | Max subscriptions to fetch (default: 20) |
Agent Workflow Examples
Browse → Read → Upvote pipeline
rdt sub python -s top -t week -n 5
rdt show 1 --expand-more
rdt upvote 1Search → Export pipeline (structured)
rdt search "machine learning" -s top --compact --json | jq '.data'
rdt export "machine learning" -n 100 -o ml_posts.csvSearch → Save to file
rdt search "rust async" -n 50 -o results.json
rdt search "python tips" -n 20 --compact -o tips.jsonUser research
rdt user spez --json | jq '.data | {name, link_karma, comment_karma}'
rdt user-posts spez -n 10 --compact --json
rdt user-comments spez -n 10 --compact --jsonSaved / Upvoted review
rdt saved -n 20 --compact --json
rdt upvoted -n 20 --compact --jsonSubscriptions-only monitoring
rdt feed --subs-only -n 5 --compact --json
rdt feed --subs-only --max-subs 10 -o subs_feed.jsonSubreddit discovery
rdt sub-info python --json | jq '.data | {subscribers, accounts_active}'
rdt sub python -s top -t month -n 5 --full-textSort Options
- Listing sort:
hot,new,top,rising,controversial,best - Search sort:
relevance,hot,top,new,comments - Time filter (for top/controversial):
hour,day,week,month,year,all - Comment sort:
best,top,new,controversial,old,qa
Error Codes
Structured error codes returned in the error.code field (see SCHEMA.md):
not_authenticated— cookies expired or missingrate_limited— too many requestsnot_found— subreddit/user/post does not existforbidden— private subreddit or blocked userapi_error— upstream Reddit API errorunknown_error— unexpected error
Limitations
- No DMs — cannot access private messages
- No live/streaming — live features not supported
- No media download — cannot download images/videos
- Single account — one set of cookies at a time
- Rate limited — built-in Gaussian jitter (~1s) between requests
- Public API only — uses .json suffix API, not OAuth endpoints
Anti-Detection Notes for Agents
- Do NOT parallelize requests — the built-in rate-limit delay is for account safety
- Write operation delay: 1.5-4s random delay after each write (upvote/save/subscribe/comment)
- Batch operations: add delays between CLI calls when doing bulk work
- Chrome 133 fingerprint: all requests use consistent browser identity
- Exponential backoff: 429/5xx errors are auto-retried with backoff
Safety Notes
- Do not ask users to share raw cookie values in chat logs
- Prefer browser cookie extraction over manual secret copy/paste
- If auth fails, ask the user to re-login via
rdt login - Built-in rate-limit delay protects accounts; do not bypass it
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
lint-and-test:
name: Lint and test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --group dev
- name: Run ruff
run: uv run ruff check .
- name: Run tests
run: uv run python -m pytest -q
build:
name: Build package
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --group dev
- name: Build distribution
run: uv build
name: Publish to PyPI
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Build package
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
__pycache__/
*.py[cod]
dist/
.venv/
*.egg-info/
.ruff_cache/
.pytest_cache/
.mypy_cache/
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "rdt-cli"
version = "0.4.2"
description = "A CLI for Reddit — browse feeds, read posts, search, and interact via terminal 📖"
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
keywords = ["reddit", "rdt", "cli", "terminal", "api"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: Utilities",
]
dependencies = [
"click>=8.0",
"rich>=13.0",
"httpx>=0.27",
"browser-cookie3>=0.19",
"pyyaml>=6.0",
]
[project.urls]
Homepage = "https://github.com/jackwener/rdt-cli"
Repository = "https://github.com/jackwener/rdt-cli"
Issues = "https://github.com/jackwener/rdt-cli/issues"
[project.scripts]
rdt = "rdt_cli.cli:cli"
[tool.hatch.build.targets.wheel]
packages = ["rdt_cli"]
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
check_untyped_defs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-m 'not smoke'"
markers = ["smoke: end-to-end tests requiring live cookies"]
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.4"]
"""Reddit CLI."""
__version__ = "0.4.2"
"""Allow running as `python -m rdt_cli`."""
from rdt_cli.cli import cli
cli()
"""Authentication for Reddit CLI.
Strategy:
1. Try loading saved credential from ~/.config/rdt-cli/credential.json
2. Try extracting cookies from local browsers via browser-cookie3
3. No QR login — Reddit uses OAuth/cookie-based auth only
"""
from __future__ import annotations
import json
import logging
import shutil
import subprocess
import time
from typing import Any
from .constants import CONFIG_DIR, CREDENTIAL_FILE, REQUIRED_COOKIES
logger = logging.getLogger(__name__)
# Credential TTL: attempt refresh after 7 days
CREDENTIAL_TTL_DAYS = 7
_CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
# ── Credential data class ───────────────────────────────────────────
class Credential:
"""Holds Reddit session cookies."""
def __init__(
self,
cookies: dict[str, str],
*,
source: str = "unknown",
username: str | None = None,
modhash: str | None = None,
saved_at: float | None = None,
last_verified_at: float | None = None,
):
self.cookies = cookies
self.source = source
self.username = username
self.modhash = modhash
self.saved_at = saved_at
self.last_verified_at = last_verified_at
@property
def is_valid(self) -> bool:
return bool(self.cookies)
def to_dict(self) -> dict[str, Any]:
saved_at = self.saved_at or time.time()
return {
"cookies": self.cookies,
"source": self.source,
"username": self.username,
"modhash": self.modhash,
"saved_at": saved_at,
"last_verified_at": self.last_verified_at,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Credential:
return cls(
cookies=data.get("cookies", {}),
source=data.get("source", "saved"),
username=data.get("username"),
modhash=data.get("modhash"),
saved_at=data.get("saved_at"),
last_verified_at=data.get("last_verified_at"),
)
def as_cookie_header(self) -> str:
return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
# ── Persistence ─────────────────────────────────────────────────────
def save_credential(credential: Credential) -> None:
"""Save credential to disk with restricted permissions."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if credential.saved_at is None:
credential.saved_at = time.time()
CREDENTIAL_FILE.write_text(json.dumps(credential.to_dict(), indent=2, ensure_ascii=False))
CREDENTIAL_FILE.chmod(0o600)
def load_credential() -> Credential | None:
"""Load saved credential with TTL-based auto-refresh."""
if not CREDENTIAL_FILE.exists():
return None
try:
data = json.loads(CREDENTIAL_FILE.read_text())
cred = Credential.from_dict(data)
if not cred.is_valid:
return None
# TTL check — auto-refresh if stale
saved_at = data.get("saved_at", 0)
if saved_at and (time.time() - saved_at) > _CREDENTIAL_TTL_SECONDS:
logger.info("Credential older than %d days, attempting browser refresh", CREDENTIAL_TTL_DAYS)
fresh = extract_browser_credential()
if fresh:
logger.info("Auto-refreshed credential from browser")
return fresh
logger.warning("Cookie refresh failed; using existing cookies")
return cred
except (json.JSONDecodeError, KeyError):
return None
def clear_credential() -> None:
"""Remove saved credential file."""
if CREDENTIAL_FILE.exists():
CREDENTIAL_FILE.unlink()
# ── Browser cookie extraction ───────────────────────────────────────
def extract_browser_credential() -> Credential | None:
"""Extract Reddit cookies from installed browsers.
Uses subprocess to avoid SQLite lock when browser is running.
"""
if shutil.which("uv"):
cred = _extract_subprocess()
if cred:
return cred
return _extract_direct()
def _extract_subprocess() -> Credential | None:
"""Extract via uv subprocess — avoids SQLite lock."""
script = '''
import browser_cookie3, json
cookies = {}
for browser_fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
try:
jar = browser_fn(domain_name=".reddit.com")
for c in jar:
cookies[c.name] = c.value
if cookies:
break
except Exception:
continue
if cookies:
print(json.dumps(cookies))
'''
try:
result = subprocess.run(
["uv", "run", "--with", "browser-cookie3", "python3", "-c", script],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0 and result.stdout.strip():
cookies = json.loads(result.stdout.strip())
if any(k in cookies for k in REQUIRED_COOKIES):
cred = Credential(cookies=cookies, source="browser:subprocess")
save_credential(cred)
return cred
except Exception as e:
logger.debug("Subprocess extraction failed: %s", e)
return None
def _extract_direct() -> Credential | None:
"""Fallback direct extraction (may fail if browser is open)."""
try:
import browser_cookie3
except ImportError:
logger.warning("browser-cookie3 not available for direct extraction")
return None
for fn in [browser_cookie3.chrome, browser_cookie3.firefox, browser_cookie3.edge, browser_cookie3.brave]:
try:
jar = fn(domain_name=".reddit.com")
cookies = {c.name: c.value for c in jar}
if any(k in cookies for k in REQUIRED_COOKIES):
cred = Credential(cookies=cookies, source=f"browser:{fn.__name__}")
save_credential(cred)
return cred
except Exception:
continue
return None
# ── Credential chain ────────────────────────────────────────────────
def get_credential() -> Credential | None:
"""Try saved → browser → return None."""
cred = load_credential()
if cred:
return cred
cred = extract_browser_credential()
if cred:
return cred
return None
"""CLI entry point for rdt-cli.
Usage:
rdt login / status / logout
rdt feed / popular / all / sub <subreddit>
rdt read <post_id> / show <index> / open <id_or_index>
rdt search <query> / export <query>
rdt user <username> / user-posts <username> / user-comments <username>
rdt saved / upvoted
rdt upvote / save / subscribe / comment
"""
from __future__ import annotations
import logging
import click
from . import __version__
from .commands import auth, browse, post, search, social
@click.group()
@click.version_option(version=__version__, prog_name="rdt")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging (show request URLs, timing)")
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
"""rdt — Reddit in your terminal 📖"""
ctx.ensure_object(dict)
logging.basicConfig(
level=logging.INFO if verbose else logging.WARNING,
format="%(name)s %(message)s",
)
# ─── Auth commands ───────────────────────────────────────────────────
cli.add_command(auth.login)
cli.add_command(auth.logout)
cli.add_command(auth.status)
cli.add_command(auth.whoami)
# ─── Browse commands ─────────────────────────────────────────────────
cli.add_command(browse.feed)
cli.add_command(browse.popular)
cli.add_command(browse.all_cmd)
cli.add_command(browse.sub)
cli.add_command(browse.sub_info)
cli.add_command(browse.user)
cli.add_command(browse.user_posts)
cli.add_command(browse.user_comments)
cli.add_command(browse.saved)
cli.add_command(browse.upvoted)
cli.add_command(browse.open_post)
# ─── Post commands ───────────────────────────────────────────────────
cli.add_command(post.read)
cli.add_command(post.show)
# ─── Search & Export ─────────────────────────────────────────────────
cli.add_command(search.search)
cli.add_command(search.export)
# ─── Social commands ────────────────────────────────────────────────
cli.add_command(social.upvote)
cli.add_command(social.save)
cli.add_command(social.subscribe)
cli.add_command(social.comment)
if __name__ == "__main__":
cli()
"""API client for Reddit with rate limiting, retry, and anti-detection."""
from __future__ import annotations
import logging
from typing import Any
from .config import DEFAULT_CONFIG, RuntimeConfig
from .constants import (
ALL_URL,
COMMENT_URL,
DEFAULT_LIMIT,
HOME_URL,
MORECHILDREN_URL,
POPULAR_URL,
POST_COMMENTS_SHORT_URL,
POST_COMMENTS_URL,
SAVE_URL,
SEARCH_URL,
SUBREDDIT_ABOUT_URL,
SUBREDDIT_SEARCH_URL,
SUBSCRIBE_URL,
SUBSCRIPTIONS_URL,
UNSAVE_URL,
USER_ABOUT_URL,
USER_COMMENTS_URL,
USER_POSTS_URL,
USER_SAVED_URL,
USER_UPVOTED_URL,
VOTE_URL,
)
from .exceptions import (
RedditApiError,
)
from .fingerprint import BrowserFingerprint
from .session import SessionState
from .transports import ReadTransport, WriteTransport
logger = logging.getLogger(__name__)
class RedditClient:
"""Reddit API client with Gaussian jitter, exponential backoff, and session-stable identity.
Anti-detection strategy:
- Gaussian jitter delay between requests
- 5% chance of random long pause (2-5s) to mimic reading
- Exponential backoff on HTTP 429/5xx (up to 3 retries)
- Response cookies merged back into session jar
- Per-request logging with counter
"""
def __init__(
self,
credential: object | None = None,
timeout: float = 30.0,
request_delay: float = 1.0,
max_retries: int = 3,
):
self.credential = credential
self._timeout = timeout
self._request_delay = request_delay
self._max_retries = max_retries
self._config = RuntimeConfig(
timeout=timeout,
read_request_delay=request_delay,
write_request_delay=max(request_delay, DEFAULT_CONFIG.write_request_delay),
max_retries=max_retries,
status_check_timeout=min(timeout, DEFAULT_CONFIG.status_check_timeout),
)
self._fingerprint = BrowserFingerprint.chrome133_mac()
self.session = SessionState.from_credential(credential)
self._read_transport: ReadTransport | None = None
self._write_transport: WriteTransport | None = None
self._http = None
@property
def client(self):
if not self._read_transport:
raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
return self._read_transport.client
def __enter__(self) -> RedditClient:
self._read_transport = ReadTransport(
self.session,
config=self._config,
fingerprint=self._fingerprint,
request_delay=self._config.read_request_delay,
)
self._write_transport = WriteTransport(
self.session,
config=self._config,
fingerprint=self._fingerprint,
request_delay=self._config.write_request_delay,
)
self._http = self._read_transport.client
return self
def __exit__(self, *args: Any) -> None:
if self._read_transport:
self._read_transport.close()
self._read_transport = None
if self._write_transport:
self._write_transport.close()
self._write_transport = None
self._http = None
@property
def request_stats(self) -> dict[str, int]:
read_count = self._read_transport.request_count if self._read_transport else 0
write_count = self._write_transport.request_count if self._write_transport else 0
return {"request_count": read_count + write_count}
# ── Core request ────────────────────────────────────────────────
def _request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Read request through the low-risk transport."""
if not self._read_transport:
raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
return self._read_transport.request(method, url, **kwargs)
def _write_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Write request through the authenticated transport."""
if not self._write_transport:
raise RuntimeError("Client not initialized. Use 'with RedditClient() as client:'")
return self._write_transport.request(method, url, **kwargs)
def _get(self, url: str, params: dict[str, Any] | None = None) -> Any:
"""GET request."""
return self._request("GET", url, params=params)
def _post(self, url: str, data: dict[str, Any] | None = None) -> Any:
"""POST request."""
return self._write_request("POST", url, data=data)
# ── Listing helpers ─────────────────────────────────────────────
@staticmethod
def _extract_posts(data: dict) -> list[dict]:
"""Extract post list from Reddit Listing response."""
if isinstance(data, list):
# Comments endpoint returns [post_listing, comments_listing]
return data
children = data.get("data", {}).get("children", [])
return [child.get("data", child) for child in children]
@staticmethod
def _extract_after(data: dict) -> str | None:
"""Extract pagination cursor."""
if isinstance(data, list):
return None
return data.get("data", {}).get("after")
# ── Feed / Listing endpoints ────────────────────────────────────
def get_home(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get home feed (requires login)."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(HOME_URL, params=params)
def get_popular(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get /r/popular."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(POPULAR_URL, params=params)
def get_all(self, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get /r/all."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(ALL_URL, params=params)
def get_subreddit(
self,
subreddit: str,
sort: str = "hot",
limit: int = DEFAULT_LIMIT,
after: str | None = None,
time_filter: str | None = None,
) -> dict:
"""Get subreddit listing."""
url = f"/r/{subreddit}.json" if sort == "hot" else f"/r/{subreddit}/{sort}.json"
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
if time_filter and sort in ("top", "controversial"):
params["t"] = time_filter
return self._get(url, params=params)
def get_subreddit_about(self, subreddit: str) -> dict:
"""Get subreddit info."""
data = self._get(SUBREDDIT_ABOUT_URL.format(subreddit=subreddit), params={"raw_json": 1})
return data.get("data", data)
# ── Post / Comments ─────────────────────────────────────────────
def get_post_comments(
self,
post_id: str,
subreddit: str | None = None,
sort: str = "best",
limit: int = DEFAULT_LIMIT,
) -> list[dict]:
"""Get post and its comments.
Returns [post_listing, comments_listing].
"""
if subreddit:
url = POST_COMMENTS_URL.format(subreddit=subreddit, post_id=post_id)
else:
url = POST_COMMENTS_SHORT_URL.format(post_id=post_id)
params: dict[str, Any] = {"sort": sort, "limit": limit, "raw_json": 1}
return self._get(url, params=params)
def get_more_comments(
self,
post_id: str,
children: list[str],
*,
sort: str = "best",
) -> dict:
"""Expand additional comments for a post."""
if not children:
return {"json": {"data": {"things": []}}}
params: dict[str, Any] = {
"api_type": "json",
"link_id": f"t3_{post_id}",
"children": ",".join(children),
"sort": sort,
"limit_children": False,
"raw_json": 1,
}
return self._get(MORECHILDREN_URL, params=params)
# ── Search ──────────────────────────────────────────────────────
def search(
self,
query: str,
subreddit: str | None = None,
sort: str = "relevance",
time_filter: str = "all",
limit: int = DEFAULT_LIMIT,
after: str | None = None,
) -> dict:
"""Search posts."""
if subreddit:
url = SUBREDDIT_SEARCH_URL.format(subreddit=subreddit)
else:
url = SEARCH_URL
params: dict[str, Any] = {
"q": query,
"sort": sort,
"t": time_filter,
"limit": limit,
"restrict_sr": "on" if subreddit else "off",
"raw_json": 1,
}
if after:
params["after"] = after
return self._get(url, params=params)
# ── User ────────────────────────────────────────────────────────
def get_user_about(self, username: str) -> dict:
"""Get user profile info."""
data = self._get(USER_ABOUT_URL.format(username=username), params={"raw_json": 1})
return data.get("data", data)
def get_user_posts(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get user's submitted posts."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(USER_POSTS_URL.format(username=username), params=params)
def get_user_comments(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get user's comments."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(USER_COMMENTS_URL.format(username=username), params=params)
def get_user_saved(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get user's saved items."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(USER_SAVED_URL.format(username=username), params=params)
def get_user_upvoted(self, username: str, limit: int = DEFAULT_LIMIT, after: str | None = None) -> dict:
"""Get user's upvoted items."""
params: dict[str, Any] = {"limit": limit, "raw_json": 1}
if after:
params["after"] = after
return self._get(USER_UPVOTED_URL.format(username=username), params=params)
# ── Identity (requires auth) ────────────────────────────────────
def get_me(self) -> dict:
"""Get current user info and enrich session capabilities."""
data = self._get("/api/me.json", params={"raw_json": 1})
if isinstance(data, dict):
self.session.apply_identity(data)
return data
def validate_session(self) -> dict[str, Any]:
"""Probe a lightweight auth endpoint to classify current credential."""
try:
identity = self.get_me()
return {
"authenticated": True,
"username": self.session.username,
"capabilities": sorted(self.session.capabilities),
"modhash_present": bool(self.session.modhash),
"identity": identity,
}
except RedditApiError as exc:
self.session.apply_validation_error(str(exc))
return {
"authenticated": False,
"username": self.session.username,
"capabilities": sorted(self.session.capabilities),
"modhash_present": bool(self.session.modhash),
"error": str(exc),
}
# ── Write actions (require authentication) ──────────────────────
def vote(self, fullname: str, direction: int) -> dict:
"""Vote on a post or comment. direction: 1=upvote, 0=unvote, -1=downvote."""
return self._post(VOTE_URL, data={"id": fullname, "dir": str(direction)})
def save_item(self, fullname: str) -> dict:
"""Save a post or comment."""
return self._post(SAVE_URL, data={"id": fullname})
def unsave_item(self, fullname: str) -> dict:
"""Unsave a post or comment."""
return self._post(UNSAVE_URL, data={"id": fullname})
def subscribe(self, subreddit: str, action: str = "sub") -> dict:
"""Subscribe or unsubscribe. action: 'sub' or 'unsub'."""
return self._post(SUBSCRIBE_URL, data={"sr_name": subreddit, "action": action})
def post_comment(self, parent_fullname: str, text: str) -> dict:
"""Post a comment."""
return self._post(COMMENT_URL, data={"parent": parent_fullname, "text": text})
# ── Subscription feed ───────────────────────────────────────────
def get_my_subscriptions(
self, limit: int = 100, max_subs: int = 20,
) -> list[str]:
"""Get names of subscribed subreddits (up to max_subs)."""
names: list[str] = []
after: str | None = None
while len(names) < max_subs:
params: dict[str, Any] = {"limit": min(limit, 100), "raw_json": 1}
if after:
params["after"] = after
data = self._get(SUBSCRIPTIONS_URL, params=params)
children = data.get("data", {}).get("children", [])
if not children:
break
for child in children:
name = child.get("data", {}).get("display_name", "")
if name:
names.append(name)
if len(names) >= max_subs:
break
after = data.get("data", {}).get("after")
if not after:
break
return names
def get_subs_only_feed(
self,
limit_per_sub: int = DEFAULT_LIMIT,
max_subs: int = 20,
on_progress: Any = None,
) -> dict:
"""Aggregate newest posts from subscribed subreddits.
Returns a synthetic listing dict compatible with parse_listing().
"""
subs = self.get_my_subscriptions(max_subs=max_subs)
if not subs:
return {"data": {"children": [], "after": None}}
all_posts: list[dict] = []
seen_ids: set[str] = set()
for i, sub_name in enumerate(subs):
if on_progress:
on_progress(i + 1, len(subs), sub_name)
try:
data = self.get_subreddit(sub_name, sort="new", limit=limit_per_sub)
children = data.get("data", {}).get("children", [])
for child in children:
post = child.get("data", child)
pid = post.get("id", "")
if pid and pid not in seen_ids:
seen_ids.add(pid)
all_posts.append(child if "data" in child else {"data": child})
except RedditApiError as exc:
logger.warning("Skipping r/%s: %s", sub_name, exc)
# Sort by created_utc descending
all_posts.sort(
key=lambda c: c.get("data", {}).get("created_utc", 0),
reverse=True,
)
return {"data": {"children": all_posts, "after": None}}
"""Common helpers for Reddit CLI commands."""
from __future__ import annotations
import json
import os
import platform
import subprocess
import sys
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any, TypeVar
import click
from rich.console import Console
from ..auth import Credential, get_credential
from ..client import RedditClient
from ..exceptions import RedditApiError, SessionExpiredError, error_code_for_exception
T = TypeVar("T")
console = Console(stderr=True)
error_console = Console(stderr=True)
_stdout = Console()
_SCHEMA_VERSION = "1"
_OUTPUT_ENV = "OUTPUT"
# ── Shared formatters (DRY — used by browse, search, post) ──────────
def format_score(score: int) -> str:
"""Format score as human-readable string (e.g., 1.2k)."""
if score >= 1000:
return f"{score / 1000:.1f}k"
return str(score)
def format_time(ts: float) -> str:
"""Format Unix timestamp to relative time string."""
if not ts:
return "-"
now = datetime.now(timezone.utc).timestamp()
diff = now - ts
if diff < 0:
return "just now"
if diff < 60:
return f"{int(diff)}s ago"
if diff < 3600:
return f"{int(diff / 60)}m ago"
if diff < 86400:
return f"{int(diff / 3600)}h ago"
if diff < 604800:
return f"{int(diff / 86400)}d ago"
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
# ── Output format resolution ────────────────────────────────────────
def resolve_output_format(*, as_json: bool, as_yaml: bool) -> str | None:
"""Resolve explicit flags first, then env override, then TTY default.
Returns "json", "yaml", or None (for rich rendering).
"""
if as_json and as_yaml:
raise click.UsageError("Use only one of --json or --yaml.")
if as_json:
return "json"
if as_yaml:
return "yaml"
output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
if output_mode == "yaml":
return "yaml"
if output_mode == "json":
return "json"
if output_mode == "rich":
return None
if not sys.stdout.isatty():
return "yaml"
return None
# ── Structured output (stable agent envelope) ──────────────────────
def success_payload(data: Any) -> dict[str, Any]:
"""Wrap structured success data in the shared agent schema."""
return {
"ok": True,
"schema_version": _SCHEMA_VERSION,
"data": data,
}
def error_payload(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
"""Wrap structured error data in the shared agent schema."""
error: dict[str, Any] = {
"code": code,
"message": message,
}
if details is not None:
error["details"] = details
return {
"ok": False,
"schema_version": _SCHEMA_VERSION,
"error": error,
}
def print_json(data: Any) -> None:
"""Print raw JSON output to stdout."""
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
def print_yaml(data: Any) -> None:
"""Print raw YAML output to stdout."""
try:
import yaml
click.echo(yaml.dump(
data, allow_unicode=True, default_flow_style=False,
sort_keys=False, default_style='"', width=1000,
))
except ImportError:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
def maybe_print_structured(data: Any, *, as_json: bool, as_yaml: bool) -> bool:
"""Print structured output (with envelope) when requested or when stdout is non-TTY.
Returns True if output was printed, False if rich rendering should be used.
"""
fmt = resolve_output_format(as_json=as_json, as_yaml=as_yaml)
if not fmt:
return False
payload = success_payload(data)
if fmt == "json":
print_json(payload)
else:
print_yaml(payload)
return True
def emit_error(
code: str,
message: str,
*,
as_json: bool | None = None,
as_yaml: bool | None = None,
details: Any | None = None,
) -> bool:
"""Emit a structured error when the active output mode is machine-readable.
Returns True if the error was emitted as structured output.
"""
if as_json is None or as_yaml is None:
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
as_json = bool(params.get("as_json", False)) if as_json is None else as_json
as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml
fmt = resolve_output_format(as_json=bool(as_json), as_yaml=bool(as_yaml))
if fmt is None:
return False
payload = error_payload(code, message, details=details)
if fmt == "json":
print_json(payload)
else:
print_yaml(payload)
return True
# ── Auth / Client helpers ───────────────────────────────────────────
def require_auth() -> Credential:
"""Get credential or exit with error."""
cred = get_credential()
if not cred:
console.print("[yellow]⚠️ Not logged in[/yellow]. Use [bold]rdt login[/bold] to authenticate")
sys.exit(1)
return cred
def optional_auth() -> Credential | None:
"""Get credential if available, or None (for public endpoints)."""
return get_credential()
def get_client(credential: Credential | None = None) -> RedditClient:
"""Create a RedditClient with optional credential."""
return RedditClient(credential)
def run_client_action(credential: Credential | None, action: Callable[[RedditClient], T]) -> T:
"""Run a client action with auto-retry on session expiry."""
try:
with get_client(credential) as client:
return action(client)
except SessionExpiredError:
from ..auth import extract_browser_credential
fresh = extract_browser_credential()
if fresh:
with get_client(fresh) as client:
return action(client)
raise
def handle_command(
credential: Credential | None,
*,
action: Callable[[RedditClient], T],
render: Callable[[T], None] | None = None,
as_json: bool = False,
as_yaml: bool = False,
) -> T | None:
"""Run a client action with structured output support.
- --json → JSON stdout (with envelope)
- --yaml or non-TTY → YAML (with envelope)
- Otherwise → rich render
On error: emits structured error + exit(1).
"""
try:
data = run_client_action(credential, action)
if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
return data
if render:
render(data)
return data
except RedditApiError as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
return None # unreachable, but for type checker
def handle_errors(fn: Callable[[], T], *, as_json: bool = False, as_yaml: bool = False) -> T | None:
"""Run arbitrary command logic and catch RedditApiError."""
try:
return fn()
except RedditApiError as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
return None
def exit_for_error(
exc: Exception,
*,
as_json: bool = False,
as_yaml: bool = False,
prefix: str | None = None,
) -> None:
"""Emit a structured/non-structured error and terminate the command."""
message = str(exc)
if prefix:
message = f"{prefix}: {message}"
code = error_code_for_exception(exc)
if emit_error(code, message, as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
error_console.print(f"[red]❌ [{code}] {message}[/red]")
raise SystemExit(1) from None
def structured_output_options(command: Callable) -> Callable:
"""Add --json/--yaml options to a Click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
return command
def listing_options(command: Callable) -> Callable:
"""Add --json/--yaml/--output/--full-text/--compact options to listing commands."""
command = click.option(
"-c", "--compact", is_flag=True,
help="Compact output (fewer fields, agent-friendly)",
)(command)
command = click.option(
"--full-text", "full_text", is_flag=True,
help="Show full title/text without truncation",
)(command)
command = click.option(
"-o", "--output", "output_file", default=None,
help="Save structured output to file (JSON/YAML)",
)(command)
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
return command
def output_or_render(data: Any, *, as_json: bool, as_yaml: bool, render: Callable) -> None:
"""DRY output routing: JSON / YAML (with envelope) / Rich."""
if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
return
render(data)
def save_output_to_file(data: Any, output_file: str) -> None:
"""Save structured output to a file (auto-detect JSON/YAML by extension)."""
payload = success_payload(data)
ext = output_file.rsplit(".", 1)[-1].lower() if "." in output_file else "json"
if ext in ("yml", "yaml"):
try:
import yaml
text = yaml.dump(
payload, allow_unicode=True, default_flow_style=False,
sort_keys=False, default_style='"', width=1000,
)
except ImportError:
text = json.dumps(payload, indent=2, ensure_ascii=False)
else:
text = json.dumps(payload, indent=2, ensure_ascii=False)
with open(output_file, "w", encoding="utf-8") as f:
f.write(text)
console.print(f"[green]✅ Saved to {output_file}[/green]")
def compact_posts(posts: list[dict]) -> list[dict]:
"""Strip non-essential fields for agent-friendly compact output."""
keep = {"id", "name", "title", "subreddit", "author", "score", "num_comments", "permalink", "url", "created_utc"}
return [{k: v for k, v in p.items() if k in keep} for p in posts]
def compact_post_detail(detail: Any) -> dict[str, Any]:
"""Flatten a PostDetail into a compact, agent-friendly structure."""
from ..models import PostDetail
from ..parser import parse_post_detail
if not isinstance(detail, PostDetail):
detail = parse_post_detail(detail)
post = detail.post
compact_post = {
"id": post.id,
"title": post.title,
"subreddit": post.subreddit,
"author": post.author,
"score": post.score,
"num_comments": post.num_comments,
"selftext": post.selftext,
"url": post.url,
"permalink": post.permalink,
}
def _flatten_comments(comments: list, depth: int = 0) -> list[dict]:
flat: list[dict] = []
for c in comments:
if c.author == "[more]":
continue
flat.append({
"author": c.author,
"score": c.score,
"body": c.body,
"depth": depth,
})
flat.extend(_flatten_comments(c.replies, depth + 1))
return flat
return {
"post": compact_post,
"comments": _flatten_comments(detail.comments),
}
def write_delay() -> None:
"""Random delay for write operations (1.5-4s) to mitigate rate limits."""
import random
import time
delay = random.uniform(1.5, 4.0)
time.sleep(delay)
def open_url(url: str) -> None:
"""Open a URL in the default browser."""
system = platform.system()
try:
if system == "Darwin":
subprocess.run(["open", url], check=True)
elif system == "Linux":
subprocess.run(["xdg-open", url], check=True)
elif system == "Windows":
subprocess.run(["start", url], check=True, shell=True)
else:
click.echo(url)
except (FileNotFoundError, subprocess.CalledProcessError):
click.echo(url)
"""Auth commands: login, logout, status, whoami."""
from __future__ import annotations
import click
from ._common import (
console,
handle_command,
maybe_print_structured,
require_auth,
structured_output_options,
)
@click.command()
def login() -> None:
"""Extract browser cookies for Reddit authentication"""
from ..auth import extract_browser_credential, get_credential
# Check if already logged in
cred = get_credential()
if cred:
console.print("[green]✅ Already authenticated[/green]")
return
console.print("[dim]🔍 Searching for Reddit cookies in browsers...[/dim]")
cred = extract_browser_credential()
if cred:
console.print(f"[green]✅ Login successful![/green] ({len(cred.cookies)} cookies extracted)")
else:
console.print("[red]❌ No Reddit cookies found.[/red]")
console.print(" [dim]Please login to reddit.com in your browser first, then retry.[/dim]")
@click.command()
def logout() -> None:
"""Clear saved Reddit cookies"""
from ..auth import clear_credential
clear_credential()
console.print("[green]✅ Credentials cleared[/green]")
@click.command()
@structured_output_options
def status(as_json: bool, as_yaml: bool) -> None:
"""Check authentication status"""
from ..auth import CREDENTIAL_FILE, get_credential
from ..client import RedditClient
from ..session import summarize_session
cred = get_credential()
state = summarize_session(RedditClient(cred).session)
info = {
"authenticated": state.authenticated,
"cookie_count": state.cookie_count,
"credential_file": str(CREDENTIAL_FILE),
"source": state.source,
"username": state.username,
"capabilities": list(state.capabilities),
"modhash_present": state.modhash_present,
"last_verified_at": state.last_verified_at,
"error": state.error,
}
if cred:
with RedditClient(cred) as client:
result = client.validate_session()
state = summarize_session(client.session)
info.update(
{
"authenticated": result["authenticated"],
"username": result.get("username") or state.username,
"capabilities": result.get("capabilities", list(state.capabilities)),
"modhash_present": result.get("modhash_present", state.modhash_present),
"last_verified_at": state.last_verified_at,
"error": result.get("error"),
}
)
if maybe_print_structured(info, as_json=as_json, as_yaml=as_yaml):
return
if info["authenticated"]:
console.print(f"[green]✅ Authenticated[/green] ({info['cookie_count']} cookies)")
if info["username"]:
console.print(f" [dim]user: {info['username']}[/dim]")
console.print(f" [dim]capabilities: {', '.join(info['capabilities']) or '-'}[/dim]")
console.print(f" [dim]source: {info['source']}[/dim]")
else:
console.print("[yellow]⚠️ Not authenticated[/yellow]")
if info["error"]:
console.print(f" [dim]{info['error']}[/dim]")
console.print(" [dim]Use 'rdt login' to extract cookies from your browser[/dim]")
@click.command()
@structured_output_options
def whoami(as_json: bool, as_yaml: bool) -> None:
"""Show current user profile (karma, account age)"""
from rich.panel import Panel
from ._common import format_time
cred = require_auth()
def _render(data: dict) -> None:
name = data.get("name", "?")
karma_post = data.get("link_karma", 0)
karma_comment = data.get("comment_karma", 0)
total_karma = data.get("total_karma", karma_post + karma_comment)
created = data.get("created_utc", 0)
is_gold = "⭐ " if data.get("is_gold") else ""
is_mod = "🛡️ " if data.get("is_mod") else ""
text = (
f"[bold cyan]u/{name}[/bold cyan] {is_gold}{is_mod}\n"
f"📊 Total karma: {total_karma:,}\n"
f" Post: {karma_post:,} · Comment: {karma_comment:,}\n"
f"📅 Joined: {format_time(created)}\n"
)
panel = Panel(text, title="👤 Me", border_style="green")
console.print(panel)
def _action(client):
me = client.get_me()
name = me.get("name") or client.session.username
if not name:
return me
profile = client.get_user_about(name)
profile.setdefault("name", name)
profile["_session"] = {
"capabilities": sorted(client.session.capabilities),
"modhash_present": bool(client.session.modhash),
}
return profile
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
"""Browse commands: feed, subreddit, popular, all, user, open."""
from __future__ import annotations
import logging
import click
from rich.panel import Panel
from rich.table import Table
from ..client import RedditClient
from ..constants import SORT_OPTIONS, TIME_FILTERS
from ..exceptions import RedditApiError
from ..index_cache import save_index
from ..parser import parse_listing, parse_subreddit_info, parse_user_profile
from ._common import (
console,
format_score,
format_time,
handle_command,
listing_options,
maybe_print_structured,
open_url,
optional_auth,
require_auth,
save_output_to_file,
structured_output_options,
)
logger = logging.getLogger(__name__)
# Default title truncation length
_TITLE_MAX = 50
_FULL_TITLE_MAX = 200
# ── Helpers ─────────────────────────────────────────────────────────
def _render_post_table(
posts, title: str,
show_subreddit: bool = True, full_text: bool = False,
) -> None:
"""Render a list of posts as a Rich table."""
if not posts:
console.print("[yellow]No posts found[/yellow]")
return
save_index([post.to_dict() for post in posts], source=title[:40])
max_title = _FULL_TITLE_MAX if full_text else _TITLE_MAX
table = Table(title=title, show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("Score", style="yellow", width=6, justify="right")
if show_subreddit:
table.add_column("Subreddit", style="magenta", max_width=15)
table.add_column(
"Title", style="bold cyan",
max_width=max_title if not full_text else None,
)
table.add_column("Author", style="green", max_width=14)
table.add_column("💬", style="dim", width=5, justify="right")
table.add_column("Time", style="dim", max_width=10)
for i, post in enumerate(posts, 1):
title_text = post.title or "-"
if post.stickied:
title_text = f"📌 {title_text}"
if post.over_18:
title_text = f"🔞 {title_text}"
if post.is_video:
title_text = f"🎬 {title_text}"
if not full_text:
title_text = title_text[:max_title]
row = [
str(i),
format_score(post.score),
]
if show_subreddit:
row.append(f"r/{post.subreddit or '?'}")
row.extend([
title_text,
(post.author or "-")[:14],
str(post.num_comments),
format_time(post.created_utc),
])
table.add_row(*row)
console.print(table)
console.print("\n [dim]💡 Use [bold]rdt show <#>[/bold] to read a post[/dim]")
def _listing_render(
data: dict, title: str,
show_subreddit: bool = True, next_cmd: str = "",
full_text: bool = False,
) -> None:
"""Common render for listing endpoints."""
listing = parse_listing(data)
_render_post_table(listing.items, title, show_subreddit=show_subreddit, full_text=full_text)
cursor = listing.after
if cursor and next_cmd:
console.print(f" [dim]▸ More: {next_cmd} --after {cursor}[/dim]")
def _handle_listing(
cred, *, action, data_title: str, next_cmd: str = "",
show_subreddit: bool = True,
as_json: bool, as_yaml: bool,
output_file: str | None = None,
full_text: bool = False,
compact: bool = False,
) -> None:
"""Unified listing handler with --output/--full-text/--compact support."""
from ._common import exit_for_error, run_client_action
try:
data = run_client_action(cred, action)
# --output: save to file
if output_file:
out_data = data
if compact:
out_data = [post.to_dict() for post in parse_listing(data).items]
save_output_to_file(out_data, output_file)
return
# --compact: strip fields for structured output
if compact:
data = [post.to_dict() for post in parse_listing(data).items]
if not as_json and not as_yaml:
as_yaml = True
# --json/--yaml: structured output
if maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
return
# Rich render
_listing_render(
data, data_title,
show_subreddit=show_subreddit,
next_cmd=next_cmd,
full_text=full_text,
)
except RedditApiError as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
def _resolve_current_username(client: RedditClient) -> str:
"""Resolve the current username from an authenticated session."""
identity = client.get_me()
username = identity.get("name") or client.session.username
if not username:
raise RedditApiError("Unable to resolve current username from session")
return username
# ── feed ────────────────────────────────────────────────────────────
@click.command()
@click.option("--subs-only", is_flag=True, help="Show only posts from subscribed subreddits (sorted by time)")
@click.option("--max-subs", default=20, type=int, help="Max subscriptions to fetch (default: 20)")
@click.option("-n", "--limit", default=25, type=int, help="Number of posts (default: 25)")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def feed(
subs_only: bool, max_subs: int,
limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse your home feed (requires login)"""
cred = require_auth()
if subs_only:
if after:
console.print("[yellow]⚠ --after is ignored with --subs-only[/yellow]")
def _progress(current: int, total: int, name: str) -> None:
if not (as_json or as_yaml):
console.print(f" [dim]📡 [{current}/{total}] r/{name}[/dim]")
_handle_listing(
cred,
action=lambda c: c.get_subs_only_feed(
limit_per_sub=limit, max_subs=max_subs, on_progress=_progress,
),
data_title="📡 Subscriptions Feed",
next_cmd="",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
else:
_handle_listing(
cred,
action=lambda c: c.get_home(limit=limit, after=after),
data_title="🏠 Home Feed",
next_cmd="rdt feed",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
# ── popular ─────────────────────────────────────────────────────────
@click.command()
@click.option("-n", "--limit", default=25, type=int, help="Number of posts")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def popular(
limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse /r/popular"""
cred = optional_auth()
_handle_listing(
cred,
action=lambda c: c.get_popular(limit=limit, after=after),
data_title="🔥 Popular",
next_cmd="rdt popular",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
# ── all ─────────────────────────────────────────────────────────────
@click.command(name="all")
@click.option("-n", "--limit", default=25, type=int, help="Number of posts")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def all_cmd(
limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse /r/all"""
cred = optional_auth()
_handle_listing(
cred,
action=lambda c: c.get_all(limit=limit, after=after),
data_title="🌍 r/all",
next_cmd="rdt all",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
# ── sub (subreddit) ─────────────────────────────────────────────────
@click.command()
@click.argument("subreddit")
@click.option("-s", "--sort", type=click.Choice(SORT_OPTIONS), default="hot", help="Sort order")
@click.option(
"-t", "--time", "time_filter",
type=click.Choice(TIME_FILTERS), default=None,
help="Time filter (for top/controversial)",
)
@click.option("-n", "--limit", default=25, type=int, help="Number of posts")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def sub(
subreddit: str, sort: str, time_filter: str | None, limit: int,
after: str | None, as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse a subreddit (e.g., rdt sub python)"""
cred = optional_auth()
emoji = {"hot": "🔥", "new": "🆕", "top": "🏆", "rising": "📈"}.get(sort, "📋")
_handle_listing(
cred,
action=lambda c: c.get_subreddit(
subreddit, sort=sort, limit=limit, after=after, time_filter=time_filter,
),
data_title=f"{emoji} r/{subreddit} ({sort})",
show_subreddit=False,
next_cmd=f"rdt sub {subreddit} -s {sort}",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
# ── sub-info ────────────────────────────────────────────────────────
@click.command("sub-info")
@click.argument("subreddit")
@structured_output_options
def sub_info(subreddit: str, as_json: bool, as_yaml: bool) -> None:
"""View subreddit info (subscribers, description)"""
cred = optional_auth()
def _render(data: dict) -> None:
info = parse_subreddit_info(data)
name = info.display_name_prefixed or f"r/{subreddit}"
desc = info.public_description or info.description
subs = info.subscribers
active = info.accounts_active
created = info.created_utc
nsfw = "🔞 NSFW" if info.over18 else ""
text = (
f"[bold cyan]{name}[/bold cyan] {nsfw}\n"
f"👥 {subs:,} subscribers · 🟢 {active:,} online\n"
f"📅 Created: {format_time(created)}\n"
)
if desc:
text += f"\n{desc[:300]}"
panel = Panel(text, title=f"📋 {name}", border_style="cyan")
console.print(panel)
handle_command(
cred,
action=lambda c: c.get_subreddit_about(subreddit),
render=_render, as_json=as_json, as_yaml=as_yaml,
)
# ── user ────────────────────────────────────────────────────────────
@click.command()
@click.argument("username")
@structured_output_options
def user(username: str, as_json: bool, as_yaml: bool) -> None:
"""View a user's profile"""
cred = optional_auth()
def _render(data: dict) -> None:
profile = parse_user_profile(data)
name = profile.name or username
karma_post = profile.link_karma
karma_comment = profile.comment_karma
created = profile.created_utc
is_gold = "⭐ " if profile.is_gold else ""
text = (
f"[bold cyan]u/{name}[/bold cyan] {is_gold}\n"
f"📊 Post karma: {karma_post:,} · Comment karma: {karma_comment:,}\n"
f"📅 Account age: {format_time(created)}\n"
)
panel = Panel(text, title=f"👤 u/{name}", border_style="green")
console.print(panel)
handle_command(
cred, action=lambda c: c.get_user_about(username),
render=_render, as_json=as_json, as_yaml=as_yaml,
)
# ── user-posts ──────────────────────────────────────────────────────
@click.command("user-posts")
@click.argument("username")
@click.option("-n", "--limit", default=25, type=int, help="Number of posts")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def user_posts(
username: str, limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""View a user's submitted posts"""
cred = optional_auth()
_handle_listing(
cred,
action=lambda c: c.get_user_posts(username, limit=limit, after=after),
data_title=f"📝 u/{username}'s posts",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
@click.command("user-comments")
@click.argument("username")
@click.option("-n", "--limit", default=25, type=int, help="Number of comments")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def user_comments(
username: str, limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""View a user's comments"""
cred = optional_auth()
_handle_listing(
cred,
action=lambda c: c.get_user_comments(username, limit=limit, after=after),
data_title=f"💬 u/{username}'s comments",
next_cmd=f"rdt user-comments {username}",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
@click.command()
@click.option("-n", "--limit", default=25, type=int, help="Number of saved items")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def saved(
limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse your saved posts"""
cred = require_auth()
_handle_listing(
cred,
action=lambda c: c.get_user_saved(_resolve_current_username(c), limit=limit, after=after),
data_title="🔖 Saved",
next_cmd="rdt saved",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
@click.command()
@click.option("-n", "--limit", default=25, type=int, help="Number of upvoted items")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def upvoted(
limit: int, after: str | None,
as_json: bool, as_yaml: bool,
output_file: str | None, full_text: bool, compact: bool,
) -> None:
"""Browse your upvoted posts"""
cred = require_auth()
_handle_listing(
cred,
action=lambda c: c.get_user_upvoted(_resolve_current_username(c), limit=limit, after=after),
data_title="⬆ Upvoted",
next_cmd="rdt upvoted",
as_json=as_json, as_yaml=as_yaml,
output_file=output_file, full_text=full_text, compact=compact,
)
# ── open ────────────────────────────────────────────────────────────
@click.command(name="open")
@click.argument("id_or_index")
def open_post(id_or_index: str) -> None:
"""Open a post in the browser (by ID or index number)
Examples:
rdt open 3 # open result #3 in browser
rdt open 1abc123 # open by post ID
"""
from ..index_cache import get_item_by_index
# Try as short-index
try:
idx = int(id_or_index)
item = get_item_by_index(idx)
if item:
permalink = item.get("permalink", "")
if permalink:
url = f"https://reddit.com{permalink}"
console.print(f"[dim]Opening: {url}[/dim]")
open_url(url)
return
console.print(f"[yellow]Index {idx} not found in cache[/yellow]")
return
except ValueError:
pass
# Bare ID or URL
if id_or_index.startswith("http"):
open_url(id_or_index)
else:
url = f"https://reddit.com/comments/{id_or_index}"
console.print(f"[dim]Opening: {url}[/dim]")
open_url(url)
"""Post commands: read, show, comments."""
from __future__ import annotations
import logging
import click
from rich.panel import Panel
from ..index_cache import get_index_info, get_item_by_index
from ..models import Comment, PostDetail
from ..parser import parse_morechildren_response, parse_post_detail
from ._common import (
compact_post_detail,
console,
handle_command,
optional_auth,
)
logger = logging.getLogger(__name__)
# ── Helpers ─────────────────────────────────────────────────────────
def _attach_more_comments(detail: PostDetail, more_comments: list[Comment]) -> PostDetail:
"""Attach expanded comments back into the existing tree by parent fullname."""
comment_map: dict[str, Comment] = {}
def _walk(comment: Comment) -> None:
comment_map[comment.fullname] = comment
for reply in comment.replies:
_walk(reply)
for comment in detail.comments:
_walk(comment)
for comment in more_comments:
if comment.fullname in comment_map:
continue
parent = comment.parent_fullname
if parent == detail.post.name or not parent:
detail.comments.append(comment)
_walk(comment)
continue
parent_comment = comment_map.get(parent)
if parent_comment is not None:
parent_comment.replies.append(comment)
_walk(comment)
else:
detail.comments.append(comment)
_walk(comment)
detail.more_count = max(0, detail.more_count - len(more_comments))
detail.more_children = []
return detail
def _render_post_detail(data: PostDetail | list | dict) -> None:
"""Render a post with its comments."""
detail = parse_post_detail(data)
post = detail.post
# Render post
title = post.title or "Untitled"
author = post.author or "?"
subreddit = post.subreddit or "?"
score = post.score
num_comments = post.num_comments
selftext = post.selftext
url = post.url
is_self = post.is_self
permalink = post.permalink
post_text = (
f"[bold cyan]{title}[/bold cyan]\n"
f"[dim]r/{subreddit}[/dim] · [green]u/{author}[/green] · "
f"[yellow]⬆ {score}[/yellow] · 💬 {num_comments}\n"
)
if not is_self and url:
post_text += f"\n🔗 {url}\n"
if selftext:
# Truncate very long posts
if len(selftext) > 1500:
selftext = selftext[:1500] + "\n\n... [truncated]"
post_text += f"\n{selftext}"
if permalink:
post_text += f"\n\n[dim]https://reddit.com{permalink}[/dim]"
panel = Panel(post_text, title="📰 Post", border_style="cyan")
console.print(panel)
# Render comments
if detail.comments:
console.print()
_render_comments(detail.comments, depth=0, max_depth=3)
if detail.more_count:
console.print(f"[dim]... {detail.more_count} more comments not expanded[/dim]")
def _render_comments(children, depth: int = 0, max_depth: int = 3) -> None:
"""Recursively render comment tree."""
for comment in children:
author = comment.author or "[deleted]"
body = comment.body
score = comment.score
indent = " " * depth
score_color = "yellow" if score > 0 else "red" if score < 0 else "dim"
# Truncate long comments
if len(body) > 300:
body = body[:300] + "..."
console.print(
f"{indent}[green]u/{author}[/green] [{score_color}]⬆ {score}[/{score_color}]"
)
for line in body.split("\n"):
console.print(f"{indent} {line}")
console.print()
# Render replies
if depth < max_depth:
if comment.replies:
_render_comments(comment.replies, depth=depth + 1, max_depth=max_depth)
def _post_detail_options(command):
"""Add --json/--yaml/--compact options for post detail commands."""
command = click.option(
"-c", "--compact", is_flag=True,
help="Compact output (fewer fields, agent-friendly)",
)(command)
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
return command
# ── read ────────────────────────────────────────────────────────────
@click.command()
@click.argument("post_id")
@click.option(
"-s", "--sort", default="best",
type=click.Choice(["best", "top", "new", "controversial", "old", "qa"]),
help="Comment sort",
)
@click.option("-n", "--limit", default=25, type=int, help="Number of comments")
@click.option("--expand-more", is_flag=True, help="Expand top-level 'more comments' entries")
@_post_detail_options
def read(post_id: str, sort: str, limit: int, expand_more: bool, as_json: bool, as_yaml: bool, compact: bool) -> None:
"""Read a post and its comments by ID
Example: rdt read 1abc123
"""
cred = optional_auth()
def _action(client):
raw = client.get_post_comments(post_id=post_id, sort=sort, limit=limit)
detail = parse_post_detail(raw)
if expand_more and detail.more_children:
expanded = client.get_more_comments(post_id, detail.more_children, sort=sort)
detail = _attach_more_comments(detail, parse_morechildren_response(expanded))
if compact:
return compact_post_detail(detail)
if as_json or as_yaml:
return detail.to_dict()
return detail
_as_json = as_json
_as_yaml = as_yaml
if compact and not as_json and not as_yaml:
_as_yaml = True
handle_command(cred, action=_action, render=_render_post_detail, as_json=_as_json, as_yaml=_as_yaml)
# ── show (short-index) ──────────────────────────────────────────────
@click.command()
@click.argument("index", type=int)
@click.option(
"-s", "--sort", default="best",
type=click.Choice(["best", "top", "new", "controversial", "old", "qa"]),
help="Comment sort",
)
@click.option("-n", "--limit", default=25, type=int, help="Number of comments")
@click.option("--expand-more", is_flag=True, help="Expand top-level 'more comments' entries")
@_post_detail_options
def show(index: int, sort: str, limit: int, expand_more: bool, as_json: bool, as_yaml: bool, compact: bool) -> None:
"""Read a post by its index from last listing (e.g., rdt show 3)
Use after rdt feed, rdt sub, rdt search, etc.
"""
item = get_item_by_index(index)
if not item:
info = get_index_info()
if not info.get("exists"):
console.print("[yellow]No cached results. Run rdt feed, rdt sub, or rdt search first.[/yellow]")
else:
console.print(f"[yellow]Index {index} out of range (total: {info.get('count', 0)})[/yellow]")
return
post_id = item.get("id", "")
if not post_id:
console.print("[red]❌ Cached item has no post ID[/red]")
return
# Show brief info from cache
console.print(
f" [dim]#{index}[/dim] [cyan]{item.get('title', '-')[:60]}[/cyan] "
f"[dim]r/{item.get('subreddit', '?')}[/dim] "
f"[yellow]⬆ {item.get('score', 0)}[/yellow]"
)
console.print()
# Fetch full post + comments
cred = optional_auth()
def _action(client):
raw = client.get_post_comments(post_id=post_id, sort=sort, limit=limit)
detail = parse_post_detail(raw)
if expand_more and detail.more_children:
expanded = client.get_more_comments(post_id, detail.more_children, sort=sort)
detail = _attach_more_comments(detail, parse_morechildren_response(expanded))
if compact:
return compact_post_detail(detail)
if as_json or as_yaml:
return detail.to_dict()
return detail
_as_json = as_json
_as_yaml = as_yaml
if compact and not as_json and not as_yaml:
_as_yaml = True
handle_command(cred, action=_action, render=_render_post_detail, as_json=_as_json, as_yaml=_as_yaml)
"""Search and export commands."""
from __future__ import annotations
import csv
import io
import json
import logging
import sys
import click
from rich.table import Table
from ..client import RedditClient
from ..constants import SEARCH_SORT_OPTIONS, TIME_FILTERS
from ..exceptions import RedditApiError
from ..index_cache import save_index
from ..parser import parse_listing
from ._common import (
console,
exit_for_error,
format_score,
listing_options,
maybe_print_structured,
optional_auth,
save_output_to_file,
)
logger = logging.getLogger(__name__)
def _render_search_table(
posts, query: str, full_text: bool = False,
) -> None:
"""Render search results as a Rich table."""
if not posts:
console.print(f"[yellow]No results for '{query}'[/yellow]")
return
save_index([post.to_dict() for post in posts], source=f"search:{query}")
max_title = 200 if full_text else 45
table = Table(title=f'🔍 Search: "{query}" — {len(posts)} results', show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("Score", style="yellow", width=6, justify="right")
table.add_column("Subreddit", style="magenta", max_width=15)
table.add_column(
"Title", style="bold cyan",
max_width=max_title if not full_text else None,
)
table.add_column("Author", style="green", max_width=12)
table.add_column("💬", style="dim", width=5, justify="right")
for i, post in enumerate(posts, 1):
title_text = post.title or "-"
if not full_text:
title_text = title_text[:max_title]
table.add_row(
str(i),
format_score(post.score),
f"r/{post.subreddit or '?'}",
title_text,
(post.author or "-")[:12],
str(post.num_comments),
)
console.print(table)
console.print("\n [dim]💡 Use [bold]rdt show <#>[/bold] to read a result[/dim]")
# ── search ──────────────────────────────────────────────────────────
@click.command()
@click.argument("query")
@click.option("-r", "--subreddit", default=None, help="Search within subreddit")
@click.option(
"-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS),
default="relevance", help="Sort order",
)
@click.option(
"-t", "--time", "time_filter",
type=click.Choice(TIME_FILTERS), default="all", help="Time filter",
)
@click.option("-n", "--limit", default=25, type=int, help="Number of results")
@click.option("--after", default=None, help="Pagination cursor")
@listing_options
def search(
query: str,
subreddit: str | None,
sort: str,
time_filter: str,
limit: int,
after: str | None,
as_json: bool,
as_yaml: bool,
output_file: str | None,
full_text: bool,
compact: bool,
) -> None:
"""Search Reddit posts
Examples:
rdt search "python async"
rdt search "rust vs go" -r programming --sort top --time year
"""
cred = optional_auth()
try:
with RedditClient(cred) as client:
data = client.search(
query=query,
subreddit=subreddit,
sort=sort,
time_filter=time_filter,
limit=limit,
after=after,
)
listing = parse_listing(data)
posts = listing.items
if posts:
save_index([post.to_dict() for post in posts], source=f"search:{query}")
# --output: save to file
if output_file:
out_data = [post.to_dict() for post in posts] if compact else data
save_output_to_file(out_data, output_file)
return
# --compact: strip fields for structured output
out_data = data
if compact:
out_data = [post.to_dict() for post in posts]
if not as_json and not as_yaml:
as_yaml = True
if maybe_print_structured(out_data, as_json=as_json, as_yaml=as_yaml):
# Show pagination hint
cursor = listing.after
if cursor:
console.print(
f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]',
)
return
_render_search_table(posts, query, full_text=full_text)
# Show pagination hint
cursor = listing.after
if cursor and sys.stdout.isatty():
console.print(f' [dim]▸ More: rdt search "{query}" --after {cursor}[/dim]')
except RedditApiError as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix="Search failed")
# ── export ──────────────────────────────────────────────────────────
@click.command()
@click.argument("query")
@click.option("-r", "--subreddit", default=None, help="Search within subreddit")
@click.option("-s", "--sort", type=click.Choice(SEARCH_SORT_OPTIONS), default="relevance", help="Sort order")
@click.option("-n", "--count", default=50, type=int, help="Number of results to export")
@click.option("-o", "--output", "output_file", default=None, help="Output file path")
@click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="Output format")
def export(query: str, subreddit: str | None, sort: str, count: int, output_file: str | None, fmt: str) -> None:
"""Export search results to CSV or JSON
Examples:
rdt export "machine learning" -n 100 -o results.csv
rdt export "python tips" --format json -o tips.json
"""
cred = optional_auth()
all_posts: list[dict] = []
after = None
try:
with RedditClient(cred) as client:
pages = 0
max_pages = (count + 24) // 25
while len(all_posts) < count and pages < max_pages:
data = client.search(query=query, subreddit=subreddit, sort=sort, limit=25, after=after)
posts = RedditClient._extract_posts(data)
if not posts:
break
all_posts.extend(posts)
after = RedditClient._extract_after(data)
if not after:
break
pages += 1
all_posts = all_posts[:count]
if not all_posts:
console.print(f"[yellow]No results found for '{query}'[/yellow]")
return
if fmt == "json":
text = json.dumps(all_posts, indent=2, ensure_ascii=False)
else:
buf = io.StringIO()
fieldnames = ["title", "subreddit", "author", "score", "num_comments", "url", "permalink"]
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for p in all_posts:
row = {
"title": p.get("title", ""),
"subreddit": p.get("subreddit", ""),
"author": p.get("author", ""),
"score": p.get("score", 0),
"num_comments": p.get("num_comments", 0),
"url": p.get("url", ""),
"permalink": f"https://reddit.com{p.get('permalink', '')}",
}
writer.writerow(row)
text = buf.getvalue()
if output_file:
encoding = "utf-8-sig" if fmt == "csv" else "utf-8"
with open(output_file, "w", encoding=encoding) as f:
f.write(text)
console.print(f"[green]✅ Exported {len(all_posts)} results to {output_file}[/green]")
else:
click.echo(text)
except RedditApiError as exc:
exit_for_error(exc, prefix="Export failed")
"""Social / interaction commands: upvote, save, subscribe, comment."""
from __future__ import annotations
import click
from ..client import RedditClient
from ..exceptions import RedditApiError
from ..index_cache import get_item_by_index
from ._common import console, exit_for_error, require_auth, write_delay
# ── Helpers ─────────────────────────────────────────────────────────
def _resolve_fullname(id_or_index: str) -> str | None:
"""Resolve an ID or short-index to a Reddit fullname (t3_xxx).
Accepts:
- Short index (e.g., "3") → from cache
- Bare post ID (e.g., "1abc123") → prepend t3_
- Full name (e.g., "t3_1abc123") → as-is
"""
# Try as short-index first
try:
idx = int(id_or_index)
item = get_item_by_index(idx)
if item:
name = item.get("name", "")
if name:
return name
pid = item.get("id", "")
if pid:
return f"t3_{pid}"
console.print(f"[yellow]Index {idx} not found in cache[/yellow]")
return None
except ValueError:
pass
# Full name
if id_or_index.startswith("t3_") or id_or_index.startswith("t1_"):
return id_or_index
# Bare ID → assume post
return f"t3_{id_or_index}"
# ── upvote ──────────────────────────────────────────────────────────
@click.command()
@click.argument("id_or_index")
@click.option("--undo", is_flag=True, help="Remove vote")
@click.option("--down", is_flag=True, help="Downvote instead")
def upvote(id_or_index: str, undo: bool, down: bool) -> None:
"""Upvote a post (by ID or index number)
Examples:
rdt upvote 3 # upvote result #3
rdt upvote 1abc123 # upvote by post ID
rdt upvote 3 --down # downvote
rdt upvote 3 --undo # remove vote
"""
cred = require_auth()
try:
with RedditClient(cred) as client:
client.validate_session()
fullname = _resolve_fullname(id_or_index)
if not fullname:
return
direction = 0 if undo else (-1 if down else 1)
action_label = "Unvoted" if undo else ("⬇ Downvoted" if down else "⬆ Upvoted")
client.vote(fullname, direction=direction)
write_delay()
console.print(f"[green]✅ {action_label}[/green] {fullname}")
except RedditApiError as exc:
exit_for_error(exc, prefix="Vote failed")
# ── save / unsave ──────────────────────────────────────────────────
@click.command()
@click.argument("id_or_index")
@click.option("--undo", is_flag=True, help="Unsave")
def save(id_or_index: str, undo: bool) -> None:
"""Save a post (by ID or index number)
Examples:
rdt save 3 # save result #3
rdt save 3 --undo # unsave
"""
cred = require_auth()
try:
with RedditClient(cred) as client:
client.validate_session()
fullname = _resolve_fullname(id_or_index)
if not fullname:
return
if undo:
client.unsave_item(fullname)
write_delay()
console.print(f"[green]✅ Unsaved[/green] {fullname}")
else:
client.save_item(fullname)
write_delay()
console.print(f"[green]✅ Saved[/green] {fullname}")
except RedditApiError as exc:
exit_for_error(exc, prefix="Save failed")
# ── subscribe / unsubscribe ────────────────────────────────────────
@click.command()
@click.argument("subreddit")
@click.option("--undo", is_flag=True, help="Unsubscribe")
def subscribe(subreddit: str, undo: bool) -> None:
"""Subscribe to a subreddit
Examples:
rdt subscribe python
rdt subscribe python --undo
"""
cred = require_auth()
action = "unsub" if undo else "sub"
label = "Unsubscribed from" if undo else "Subscribed to"
try:
with RedditClient(cred) as client:
client.validate_session()
client.subscribe(subreddit, action=action)
write_delay()
console.print(f"[green]✅ {label}[/green] r/{subreddit}")
except RedditApiError as exc:
exit_for_error(exc, prefix="Subscribe failed")
# ── comment ─────────────────────────────────────────────────────────
@click.command()
@click.argument("id_or_index")
@click.argument("text")
def comment(id_or_index: str, text: str) -> None:
"""Post a comment on a post (by ID or index number)
Examples:
rdt comment 3 "Great post!"
rdt comment 1abc123 "Thanks for sharing"
"""
cred = require_auth()
try:
with RedditClient(cred) as client:
client.validate_session()
fullname = _resolve_fullname(id_or_index)
if not fullname:
return
client.post_comment(fullname, text)
write_delay()
console.print(f"[green]✅ Comment posted[/green] on {fullname}")
except RedditApiError as exc:
exit_for_error(exc, prefix="Comment failed")
"""Runtime configuration for transport, auth, and anti-detection defaults."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RuntimeConfig:
"""Normalized runtime config used by transports and session validation."""
timeout: float = 30.0
read_request_delay: float = 1.0
write_request_delay: float = 2.5
max_retries: int = 3
status_check_timeout: float = 10.0
DEFAULT_CONFIG = RuntimeConfig()
"""Constants for Reddit CLI — API endpoints, headers, and config paths."""
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────
CONFIG_DIR = Path.home() / ".config" / "rdt-cli"
CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
# ── Base URL ────────────────────────────────────────────────────────
BASE_URL = "https://www.reddit.com"
OAUTH_URL = "https://oauth.reddit.com"
# ── Reddit JSON API ─────────────────────────────────────────────────
# Reddit's public JSON API: append .json to any URL
# Authenticated endpoints use oauth.reddit.com
# Listing endpoints (GET, append .json)
HOME_URL = "/.json"
POPULAR_URL = "/r/popular.json"
ALL_URL = "/r/all.json"
SUBREDDIT_URL = "/r/{subreddit}.json" # hot by default
SUBREDDIT_NEW_URL = "/r/{subreddit}/new.json"
SUBREDDIT_TOP_URL = "/r/{subreddit}/top.json"
SUBREDDIT_RISING_URL = "/r/{subreddit}/rising.json"
SUBREDDIT_ABOUT_URL = "/r/{subreddit}/about.json"
# Post / comments
POST_COMMENTS_URL = "/r/{subreddit}/comments/{post_id}.json"
POST_COMMENTS_SHORT_URL = "/comments/{post_id}.json"
MORECHILDREN_URL = "/api/morechildren.json"
# Search
SEARCH_URL = "/search.json"
SUBREDDIT_SEARCH_URL = "/r/{subreddit}/search.json"
# User
USER_ABOUT_URL = "/user/{username}/about.json"
USER_POSTS_URL = "/user/{username}/submitted.json"
USER_COMMENTS_URL = "/user/{username}/comments.json"
USER_SAVED_URL = "/user/{username}/saved.json"
USER_UPVOTED_URL = "/user/{username}/upvoted.json"
# Auth / identity (OAuth)
ME_URL = "/api/v1/me"
# Write actions (OAuth, POST)
VOTE_URL = "/api/vote"
SAVE_URL = "/api/save"
UNSAVE_URL = "/api/unsave"
SUBSCRIBE_URL = "/api/subscribe"
COMMENT_URL = "/api/comment"
SUBSCRIPTIONS_URL = "/subreddits/mine/subscriber.json"
# ── Request Headers (Chrome 133, macOS) ─────────────────────────────
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"
),
"sec-ch-ua": '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
}
# ── Cookie keys required for authenticated sessions ─────────────────
REQUIRED_COOKIES = {"reddit_session"}
# ── Sort options ────────────────────────────────────────────────────
SORT_OPTIONS = ["hot", "new", "top", "rising", "controversial", "best"]
# ── Time filter for top/controversial ───────────────────────────────
TIME_FILTERS = ["hour", "day", "week", "month", "year", "all"]
# ── Search sort options ─────────────────────────────────────────────
SEARCH_SORT_OPTIONS = ["relevance", "hot", "top", "new", "comments"]
# ── Default page size ───────────────────────────────────────────────
DEFAULT_LIMIT = 25
MAX_LIMIT = 100
"""Custom exceptions for Reddit CLI API client."""
from __future__ import annotations
class RedditApiError(Exception):
"""Base exception for Reddit API errors."""
def __init__(self, message: str, code: int | str | None = None, response: dict | None = None):
super().__init__(message)
self.code = code
self.response = response
class SessionExpiredError(RedditApiError):
"""Raised when session cookies have expired."""
def __init__(self):
super().__init__(
"Session expired. Please re-login: rdt logout && rdt login",
code=401,
)
class AuthRequiredError(RedditApiError):
"""Raised when user is not logged in."""
def __init__(self):
super().__init__("Not logged in. Use 'rdt login' to authenticate")
class RateLimitError(RedditApiError):
"""Raised when Reddit rate-limits the request."""
def __init__(self, retry_after: float | None = None):
msg = "Rate limited by Reddit"
if retry_after:
msg += f" (retry after {retry_after:.0f}s)"
super().__init__(msg, code=429)
self.retry_after = retry_after
class NotFoundError(RedditApiError):
"""Raised when a subreddit, user, or post is not found."""
def __init__(self, resource: str = "Resource"):
super().__init__(f"{resource} not found", code=404)
class ForbiddenError(RedditApiError):
"""Raised when access is forbidden (private subreddit, etc.)."""
def __init__(self, resource: str = "Resource"):
super().__init__(f"Access forbidden: {resource}", code=403)
def error_code_for_exception(exc: Exception) -> str:
"""Map domain exceptions to stable error code strings."""
if isinstance(exc, (AuthRequiredError, SessionExpiredError)):
return "not_authenticated"
if isinstance(exc, RateLimitError):
return "rate_limited"
if isinstance(exc, NotFoundError):
return "not_found"
if isinstance(exc, ForbiddenError):
return "forbidden"
if isinstance(exc, RedditApiError):
return "api_error"
return "unknown_error"
"""Browser fingerprint helpers for Reddit requests."""
from __future__ import annotations
from dataclasses import dataclass
from .constants import BASE_URL, HEADERS
@dataclass(frozen=True)
class BrowserFingerprint:
"""Consistent request fingerprint used across read and write transports."""
user_agent: str
sec_ch_ua: str
sec_ch_ua_mobile: str
sec_ch_ua_platform: str
accept_language: str
@classmethod
def chrome133_mac(cls) -> BrowserFingerprint:
return cls(
user_agent=HEADERS["User-Agent"],
sec_ch_ua=HEADERS["sec-ch-ua"],
sec_ch_ua_mobile=HEADERS["sec-ch-ua-mobile"],
sec_ch_ua_platform=HEADERS["sec-ch-ua-platform"],
accept_language=HEADERS["Accept-Language"],
)
def base_headers(self) -> dict[str, str]:
"""Headers shared by all Reddit requests."""
return {
"User-Agent": self.user_agent,
"sec-ch-ua": self.sec_ch_ua,
"sec-ch-ua-mobile": self.sec_ch_ua_mobile,
"sec-ch-ua-platform": self.sec_ch_ua_platform,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Accept": "application/json, text/plain, */*",
"Accept-Language": self.accept_language,
}
def read_headers(self) -> dict[str, str]:
"""Headers for low-risk read requests."""
return self.base_headers()
def write_headers(self, *, modhash: str | None = None) -> dict[str, str]:
"""Headers for state-changing requests."""
headers = self.base_headers()
headers.update(
{
"Origin": BASE_URL,
"Referer": f"{BASE_URL}/",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
)
if modhash:
headers["x-modhash"] = modhash
return headers
"""Search result index cache for short-index navigation (rdt show 3)."""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from .constants import CONFIG_DIR
logger = logging.getLogger(__name__)
INDEX_CACHE_FILE = CONFIG_DIR / "index_cache.json"
def save_index(items: list[dict], source: str = "search") -> None:
"""Save a list of posts/items to the index cache."""
if not items:
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for item in items:
entry = {
"id": item.get("id", ""),
"name": item.get("name", ""), # fullname like t3_abc123
"title": item.get("title", ""),
"subreddit": item.get("subreddit", ""),
"author": item.get("author", ""),
"score": item.get("score", 0),
"num_comments": item.get("num_comments", 0),
"permalink": item.get("permalink", ""),
"url": item.get("url", ""),
}
if entry["id"]:
entries.append(entry)
payload = {
"source": source,
"saved_at": time.time(),
"count": len(entries),
"items": entries,
}
INDEX_CACHE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
INDEX_CACHE_FILE.chmod(0o600)
logger.debug("Saved %d items to index cache (source=%s)", len(entries), source)
def get_item_by_index(index: int) -> dict | None:
"""Get a cached item by 1-based index."""
if index <= 0 or not INDEX_CACHE_FILE.exists():
return None
try:
data = json.loads(INDEX_CACHE_FILE.read_text())
items = data.get("items", [])
if index <= len(items):
return items[index - 1]
return None
except (OSError, json.JSONDecodeError, IndexError):
return None
def get_index_info() -> dict[str, Any]:
"""Get metadata about the current index cache."""
if not INDEX_CACHE_FILE.exists():
return {"exists": False, "count": 0}
try:
data = json.loads(INDEX_CACHE_FILE.read_text())
return {
"exists": True,
"count": data.get("count", 0),
"source": data.get("source", ""),
"saved_at": data.get("saved_at", 0),
}
except (OSError, json.JSONDecodeError):
return {"exists": False, "count": 0}
"""Typed data models for Reddit listings, posts, comments, and profiles."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
@dataclass
class Post:
id: str
name: str
title: str
subreddit: str
author: str
score: int = 0
num_comments: int = 0
created_utc: float = 0.0
permalink: str = ""
url: str = ""
selftext: str = ""
is_self: bool = True
over_18: bool = False
is_video: bool = False
stickied: bool = False
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class Comment:
id: str
fullname: str
author: str
body: str
parent_fullname: str = ""
score: int = 0
created_utc: float = 0.0
replies: list[Comment] = field(default_factory=list)
more_count: int = 0
more_children: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"fullname": self.fullname,
"author": self.author,
"body": self.body,
"parent_fullname": self.parent_fullname,
"score": self.score,
"created_utc": self.created_utc,
"more_count": self.more_count,
"more_children": list(self.more_children),
"replies": [reply.to_dict() for reply in self.replies],
}
@dataclass
class ListingPage:
items: list[Post]
after: str | None = None
before: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"items": [item.to_dict() for item in self.items],
"after": self.after,
"before": self.before,
}
@dataclass
class PostDetail:
post: Post
comments: list[Comment] = field(default_factory=list)
more_count: int = 0
more_children: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"post": self.post.to_dict(),
"comments": [comment.to_dict() for comment in self.comments],
"more_count": self.more_count,
"more_children": list(self.more_children),
}
@dataclass
class UserProfile:
name: str
link_karma: int = 0
comment_karma: int = 0
created_utc: float = 0.0
is_gold: bool = False
is_mod: bool = False
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class SubredditInfo:
display_name: str
display_name_prefixed: str
public_description: str = ""
description: str = ""
subscribers: int = 0
accounts_active: int = 0
created_utc: float = 0.0
over18: bool = False
def to_dict(self) -> dict[str, Any]:
return asdict(self)
"""Parsers for Reddit JSON payloads."""
from __future__ import annotations
from typing import Any
from .models import Comment, ListingPage, Post, PostDetail, SubredditInfo, UserProfile
def _as_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _as_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def parse_post(payload: dict[str, Any]) -> Post:
return Post(
id=str(payload.get("id", "")),
name=str(payload.get("name", payload.get("fullname", ""))),
title=str(payload.get("title", "")),
subreddit=str(payload.get("subreddit", "")),
author=str(payload.get("author", "")),
score=_as_int(payload.get("score")),
num_comments=_as_int(payload.get("num_comments")),
created_utc=_as_float(payload.get("created_utc")),
permalink=str(payload.get("permalink", "")),
url=str(payload.get("url", "")),
selftext=str(payload.get("selftext", "")),
is_self=bool(payload.get("is_self", True)),
over_18=bool(payload.get("over_18", False)),
is_video=bool(payload.get("is_video", False)),
stickied=bool(payload.get("stickied", False)),
)
def parse_listing(data: dict[str, Any]) -> ListingPage:
listing = data.get("data", {})
children = listing.get("children", [])
posts = [parse_post(child.get("data", child)) for child in children]
return ListingPage(
items=posts,
after=listing.get("after"),
before=listing.get("before"),
)
def _parse_comment_node(node: dict[str, Any]) -> Comment | None:
kind = node.get("kind")
data = node.get("data", {})
if kind == "more":
return Comment(
id=str(data.get("id", "")),
fullname=str(data.get("name", "")),
author="[more]",
body="",
parent_fullname=str(data.get("parent_id", "")),
score=0,
created_utc=0.0,
replies=[],
more_count=len(data.get("children", []) or []),
more_children=[str(child) for child in data.get("children", []) or []],
)
if kind != "t1":
return None
replies_payload = data.get("replies", {})
replies: list[Comment] = []
if isinstance(replies_payload, dict):
for child in replies_payload.get("data", {}).get("children", []):
parsed = _parse_comment_node(child)
if parsed is not None:
replies.append(parsed)
return Comment(
id=str(data.get("id", "")),
fullname=str(data.get("name", "")),
author=str(data.get("author", "[deleted]")),
body=str(data.get("body", "")),
parent_fullname=str(data.get("parent_id", "")),
score=_as_int(data.get("score")),
created_utc=_as_float(data.get("created_utc")),
replies=replies,
)
def _collect_more_ids(comment: Comment) -> list[str]:
collected: list[str] = []
for reply in comment.replies:
if reply.more_children:
collected.extend(reply.more_children)
collected.extend(_collect_more_ids(reply))
return collected
def parse_post_detail(data: list[dict[str, Any]] | dict[str, Any] | PostDetail) -> PostDetail:
if isinstance(data, PostDetail):
return data
if not isinstance(data, list) or not data:
return PostDetail(post=parse_post(data if isinstance(data, dict) else {}), comments=[])
post_listing = data[0].get("data", {}).get("children", [])
post_payload = post_listing[0].get("data", {}) if post_listing else {}
comments_listing = data[1].get("data", {}).get("children", []) if len(data) > 1 else []
comments: list[Comment] = []
more_count = 0
more_children: list[str] = []
for child in comments_listing:
parsed = _parse_comment_node(child)
if parsed is None:
continue
if parsed.author == "[more]":
more_count += parsed.more_count or 1
more_children.extend(parsed.more_children)
continue
more_children.extend(_collect_more_ids(parsed))
comments.append(parsed)
return PostDetail(
post=parse_post(post_payload),
comments=comments,
more_count=more_count,
more_children=more_children,
)
def parse_user_profile(data: dict[str, Any]) -> UserProfile:
inner = data.get("data", data)
return UserProfile(
name=str(inner.get("name", "")),
link_karma=_as_int(inner.get("link_karma")),
comment_karma=_as_int(inner.get("comment_karma")),
created_utc=_as_float(inner.get("created_utc")),
is_gold=bool(inner.get("is_gold", False)),
is_mod=bool(inner.get("is_mod", False)),
)
def parse_subreddit_info(data: dict[str, Any]) -> SubredditInfo:
inner = data.get("data", data)
display_name = str(inner.get("display_name", ""))
prefixed = str(inner.get("display_name_prefixed", f"r/{display_name}" if display_name else ""))
return SubredditInfo(
display_name=display_name,
display_name_prefixed=prefixed,
public_description=str(inner.get("public_description", "")),
description=str(inner.get("description", "")),
subscribers=_as_int(inner.get("subscribers")),
accounts_active=_as_int(inner.get("accounts_active")),
created_utc=_as_float(inner.get("created_utc")),
over18=bool(inner.get("over18", False)),
)
def compact_post_models(posts: list[Post]) -> list[dict[str, Any]]:
return [post.to_dict() for post in posts]
def parse_morechildren_response(data: dict[str, Any]) -> list[Comment]:
"""Parse /api/morechildren response into typed comments."""
things = data.get("json", {}).get("data", {}).get("things", [])
comments: list[Comment] = []
for thing in things:
parsed = _parse_comment_node(thing)
if parsed is not None and parsed.author != "[more]":
comments.append(parsed)
return comments
"""Session capability detection and validation."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .auth import Credential
def _cookie_value(cookies: dict[str, str], *names: str) -> str | None:
for name in names:
value = cookies.get(name)
if value:
return value
return None
@dataclass
class SessionState:
"""Normalized session information derived from saved/browser cookies."""
cookies: dict[str, str]
source: str = "unknown"
username: str | None = None
modhash: str | None = None
last_verified_at: float | None = None
validation_error: str | None = None
capabilities: set[str] = field(default_factory=set)
@classmethod
def from_credential(cls, credential: Credential | None) -> SessionState:
if credential is None:
return cls(cookies={}, source="none", validation_error="No credential loaded")
state = cls(
cookies=dict(credential.cookies),
source=credential.source,
username=credential.username,
modhash=credential.modhash,
last_verified_at=credential.last_verified_at,
)
state.refresh_capabilities()
return state
@property
def is_authenticated(self) -> bool:
return "read" in self.capabilities
@property
def can_write(self) -> bool:
return "write" in self.capabilities
def refresh_capabilities(self) -> None:
capabilities: set[str] = set()
if self.cookies.get("reddit_session"):
capabilities.add("read")
inferred_modhash = self.modhash or _cookie_value(self.cookies, "modhash", "csrf_token")
if inferred_modhash:
self.modhash = inferred_modhash
capabilities.add("write")
self.capabilities = capabilities
def apply_identity(self, identity: dict[str, Any]) -> None:
"""Update session from a validated identity payload."""
data = identity.get("data", identity)
name = data.get("name") or data.get("username")
if name:
self.username = name
modhash = data.get("modhash") or self.modhash or _cookie_value(self.cookies, "modhash", "csrf_token")
if modhash:
self.modhash = modhash
self.validation_error = None
self.refresh_capabilities()
def apply_validation_error(self, message: str) -> None:
self.validation_error = message
self.refresh_capabilities()
@dataclass(frozen=True)
class SessionValidationResult:
"""Result from probing the current credential."""
authenticated: bool
username: str | None
capabilities: tuple[str, ...]
source: str
cookie_count: int
modhash_present: bool
last_verified_at: float | None
error: str | None = None
def summarize_session(state: SessionState) -> SessionValidationResult:
"""Convert mutable session state to structured command output."""
capabilities = tuple(sorted(state.capabilities))
return SessionValidationResult(
authenticated=state.is_authenticated,
username=state.username,
capabilities=capabilities,
source=state.source,
cookie_count=len(state.cookies),
modhash_present=bool(state.modhash),
last_verified_at=state.last_verified_at,
error=state.validation_error,
)
"""HTTP transports for read and write Reddit requests."""
from __future__ import annotations
import logging
import random
import time
from typing import Any
import httpx
from .config import RuntimeConfig
from .constants import BASE_URL
from .exceptions import (
ForbiddenError,
NotFoundError,
RateLimitError,
RedditApiError,
SessionExpiredError,
)
from .fingerprint import BrowserFingerprint
from .session import SessionState
logger = logging.getLogger(__name__)
class BaseTransport:
"""Shared retry, throttling, and cookie management."""
def __init__(
self,
session: SessionState,
*,
config: RuntimeConfig,
fingerprint: BrowserFingerprint,
request_delay: float,
) -> None:
self.session = session
self.config = config
self.fingerprint = fingerprint
self._request_delay = request_delay
self._max_retries = config.max_retries
self._last_request_time = 0.0
self._request_count = 0
self._http = httpx.Client(
base_url=BASE_URL,
headers=self.default_headers(),
cookies=session.cookies,
follow_redirects=True,
timeout=httpx.Timeout(config.timeout),
)
def close(self) -> None:
self._http.close()
@property
def client(self) -> httpx.Client:
return self._http
@property
def request_count(self) -> int:
return self._request_count
def default_headers(self) -> dict[str, str]:
raise NotImplementedError
def _rate_limit_delay(self) -> None:
if self._request_delay <= 0:
return
elapsed = time.time() - self._last_request_time
if elapsed < self._request_delay:
jitter = max(0.0, random.gauss(0.3, 0.15))
if random.random() < 0.05:
jitter += random.uniform(2.0, 5.0)
time.sleep(self._request_delay - elapsed + jitter)
def _merge_response_cookies(self, resp: httpx.Response) -> None:
for name, value in resp.cookies.items():
if not value:
continue
self.client.cookies.set(name, value)
self.session.cookies[name] = value
self.session.refresh_capabilities()
def request(self, method: str, url: str, **kwargs: Any) -> Any:
self._rate_limit_delay()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
t0 = time.time()
try:
resp = self.client.request(method, url, **kwargs)
elapsed = time.time() - t0
self._merge_response_cookies(resp)
self._request_count += 1
self._last_request_time = time.time()
logger.info(
"[#%d] %s %s -> %d (%.2fs)",
self._request_count,
method,
url[:80],
resp.status_code,
elapsed,
)
if resp.status_code == 429:
retry_after = float(resp.headers.get("Retry-After", 5))
if attempt + 1 >= self._max_retries:
raise RateLimitError(retry_after=retry_after)
time.sleep(retry_after)
continue
if resp.status_code in (500, 502, 503, 504):
wait = (2**attempt) + random.uniform(0, 1)
logger.warning("HTTP %d, retrying in %.1fs", resp.status_code, wait)
time.sleep(wait)
continue
if resp.status_code == 401:
raise SessionExpiredError()
if resp.status_code == 403:
raise ForbiddenError()
if resp.status_code == 404:
raise NotFoundError()
resp.raise_for_status()
text = resp.text
if text.strip().startswith("<"):
raise RedditApiError("Received HTML instead of JSON (possible auth redirect)")
if not text.strip():
return {}
return resp.json()
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
wait = (2**attempt) + random.uniform(0, 1)
logger.warning("Network error: %s, retrying in %.1fs", exc, wait)
time.sleep(wait)
if last_exc:
raise RedditApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
raise RedditApiError(f"Request failed after {self._max_retries} retries")
class ReadTransport(BaseTransport):
"""Transport for low-risk listing and detail requests."""
def default_headers(self) -> dict[str, str]:
return self.fingerprint.read_headers()
class WriteTransport(BaseTransport):
"""Transport for state-changing authenticated requests."""
def default_headers(self) -> dict[str, str]:
return self.fingerprint.write_headers(modhash=self.session.modhash)
def request(self, method: str, url: str, **kwargs: Any) -> Any:
if not self.session.can_write:
raise RedditApiError("Session is not write-capable yet; run 'rdt status' or 'rdt whoami' to validate")
headers = dict(kwargs.pop("headers", {}))
headers.update(self.fingerprint.write_headers(modhash=self.session.modhash))
kwargs["headers"] = headers
data = kwargs.get("data")
if isinstance(data, dict) and self.session.modhash and "uh" not in data:
kwargs["data"] = {**data, "uh": self.session.modhash}
return super().request(method, url, **kwargs)
Structured Output Schema
rdt-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...Error
ok: false
schema_version: "1"
error:
code: not_authenticated
message: need loginNotes
--jsonand--yamlboth use this envelope- non-TTY stdout defaults to YAML
- reading and search commands return their payload under
data statusreturnsdata.authenticatedplusdata.cookie_countwhoamireturnsdata.user- common
error.codevalues includenot_authenticated,rate_limited,not_found,forbidden, andapi_error - set
OUTPUT=yaml|json|rich|autoenvironment variable to override default format
{
"data": {
"after": "t3_after",
"before": null,
"children": [
{
"kind": "t3",
"data": {
"id": "abc123",
"name": "t3_abc123",
"title": "Async Python Tips",
"subreddit": "python",
"author": "guido",
"score": 1234,
"num_comments": 56,
"created_utc": 1710000000,
"permalink": "/r/python/comments/abc123/async_python_tips/",
"url": "https://reddit.com/r/python/comments/abc123/async_python_tips/",
"selftext": "A post body",
"is_self": true,
"over_18": false,
"is_video": false,
"stickied": true
}
},
{
"kind": "t3",
"data": {
"id": "def456",
"name": "t3_def456",
"title": "Rust vs Go",
"subreddit": "programming",
"author": "alice",
"score": 77,
"num_comments": 8,
"created_utc": 1710000100,
"permalink": "/r/programming/comments/def456/rust_vs_go/",
"url": "https://example.com/rust-vs-go",
"selftext": "",
"is_self": false,
"over_18": true,
"is_video": true,
"stickied": false
}
}
]
}
}
{
"json": {
"data": {
"things": [
{
"kind": "t1",
"data": {
"id": "c3",
"name": "t1_c3",
"parent_id": "t3_abc123",
"author": "dave",
"body": "Expanded top level comment",
"score": 3,
"created_utc": 1710000400,
"replies": ""
}
},
{
"kind": "t1",
"data": {
"id": "c4",
"name": "t1_c4",
"parent_id": "t1_c1",
"author": "erin",
"body": "Expanded nested reply",
"score": 2,
"created_utc": 1710000500,
"replies": ""
}
}
]
}
}
}
[
{
"data": {
"children": [
{
"kind": "t3",
"data": {
"id": "abc123",
"name": "t3_abc123",
"title": "Async Python Tips",
"subreddit": "python",
"author": "guido",
"score": 1234,
"num_comments": 56,
"created_utc": 1710000000,
"permalink": "/r/python/comments/abc123/async_python_tips/",
"url": "https://reddit.com/r/python/comments/abc123/async_python_tips/",
"selftext": "A post body",
"is_self": true
}
}
]
}
},
{
"data": {
"children": [
{
"kind": "t1",
"data": {
"id": "c1",
"name": "t1_c1",
"author": "bob",
"body": "First comment",
"score": 10,
"created_utc": 1710000200,
"replies": {
"data": {
"children": [
{
"kind": "t1",
"data": {
"id": "c2",
"name": "t1_c2",
"author": "carol",
"body": "Nested reply",
"score": 4,
"created_utc": 1710000300,
"replies": ""
}
}
]
}
}
}
},
{
"kind": "more",
"data": {
"id": "_",
"name": "more_1",
"parent_id": "t3_abc123",
"children": ["c3", "c4", "c5"]
}
}
]
}
}
]
from __future__ import annotations
from unittest.mock import patch
from rdt_cli.auth import Credential
from rdt_cli.client import RedditClient
from rdt_cli.exceptions import RedditApiError
def test_validate_session_success_updates_username_and_capabilities() -> None:
cred = Credential(cookies={"reddit_session": "abc"})
with RedditClient(cred) as client:
with patch.object(client, "_get", return_value={"name": "spez", "modhash": "mh"}):
result = client.validate_session()
assert result["authenticated"] is True
assert result["username"] == "spez"
assert "write" in result["capabilities"]
def test_validate_session_failure_preserves_read_capability() -> None:
cred = Credential(cookies={"reddit_session": "abc"})
with RedditClient(cred) as client:
with patch.object(client, "_get", side_effect=RedditApiError("boom")):
result = client.validate_session()
assert result["authenticated"] is False
assert result["capabilities"] == ["read"]
assert result["error"] == "boom"
def test_get_user_saved_uses_saved_endpoint() -> None:
cred = Credential(cookies={"reddit_session": "abc"})
with RedditClient(cred) as client:
with patch.object(client, "_get", return_value={"ok": True}) as mock_get:
data = client.get_user_saved("spez", limit=5, after="t3_next")
assert data == {"ok": True}
mock_get.assert_called_once_with("/user/spez/saved.json", params={"limit": 5, "raw_json": 1, "after": "t3_next"})
def test_get_user_upvoted_uses_upvoted_endpoint() -> None:
cred = Credential(cookies={"reddit_session": "abc"})
with RedditClient(cred) as client:
with patch.object(client, "_get", return_value={"ok": True}) as mock_get:
data = client.get_user_upvoted("spez", limit=7)
assert data == {"ok": True}
mock_get.assert_called_once_with("/user/spez/upvoted.json", params={"limit": 7, "raw_json": 1})
def test_get_more_comments_uses_api_morechildren() -> None:
cred = Credential(cookies={"reddit_session": "abc"})
with RedditClient(cred) as client:
with patch.object(client, "_get", return_value={"json": {"data": {"things": []}}}) as mock_get:
data = client.get_more_comments("abc123", ["c3", "c4"], sort="top")
assert data["json"]["data"]["things"] == []
mock_get.assert_called_once_with(
"/api/morechildren.json",
params={
"api_type": "json",
"link_id": "t3_abc123",
"children": "c3,c4",
"sort": "top",
"limit_children": False,
"raw_json": 1,
},
)
from __future__ import annotations
from rdt_cli.auth import Credential
from rdt_cli.session import SessionState, summarize_session
def test_session_state_from_credential_read_only() -> None:
cred = Credential(cookies={"reddit_session": "abc"}, source="browser:chrome")
state = SessionState.from_credential(cred)
assert state.is_authenticated is True
assert state.can_write is False
assert state.source == "browser:chrome"
def test_session_state_infers_write_capability_from_modhash_cookie() -> None:
cred = Credential(cookies={"reddit_session": "abc", "modhash": "xyz"})
state = SessionState.from_credential(cred)
assert state.can_write is True
assert state.modhash == "xyz"
def test_session_apply_identity_upgrades_capabilities() -> None:
state = SessionState.from_credential(Credential(cookies={"reddit_session": "abc"}))
state.apply_identity({"name": "spez", "modhash": "mh"})
assert state.username == "spez"
assert state.can_write is True
def test_summarize_session_structured_fields() -> None:
state = SessionState.from_credential(
Credential(cookies={"reddit_session": "abc", "modhash": "mh"}, source="saved", username="me")
)
summary = summarize_session(state)
assert summary.authenticated is True
assert summary.modhash_present is True
assert "write" in summary.capabilities