
Reddit Skill
- 11 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
reddit-skill is a Claude skill that gives direct Reddit API access over OAuth2 via shell scripts to read users, subreddits, posts, comments, and search, with optional gated write operations.
About
This skill provides direct Reddit API access over OAuth2 using shell scripts instead of PRAW, covering users, subreddits, posts, comments, and search. A developer uses it to read Reddit data (profiles, subreddit stats, top posts, search) and optionally to post or comment under a double safety gate. It is cache-first, auto-detects app-only versus user auth mode, and requires both REDDIT_ENABLE_WRITE=1 and --confirm before any write reaches Reddit. Documentation is primarily in Russian.
- Direct Reddit API access via OAuth2 without PRAW
- Read users, subreddits, posts, comments, and search; optional gated write operations
- Cache-first with a double safety gate (REDDIT_ENABLE_WRITE=1 plus --confirm) on writes
Reddit Skill by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,469 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
reddit-skill capabilities & compatibility
Requires Reddit client id/secret and user agent; the Reddit API itself is free for this usage
- Use cases
- research · marketing · web search
- Pricing
- Bring your own API key
What reddit-skill says it does
Прямой доступ к Reddit API через OAuth2 без PRAW.
Без `REDDIT_ENABLE_WRITE=1` в `.env` write-скрипты **отказывают сразу** с понятной ошибкой
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill reddit-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Access the Reddit API over OAuth2 to read users, subreddits, posts, and search, with optional double-gated writes.
Who is it for?
Developers who need to read Reddit data or post/comment programmatically with safety gates
When should I use this skill?
The user says 'reddit', 'reddit api', 'reddit subreddit', 'reddit search', or wants to parse Reddit posts and comments
What you get
Reddit user, subreddit, post, comment, and search data retrieved, with writes only after a double confirmation.
- reddit read data (users, subreddits, posts, search)
- optional gated posts and comments
By the numbers
- 3-level write safety (env gate, dry-run, --confirm)
- one retry on 429 if wait is under 60s
Files
reddit-skill
Прямой доступ к Reddit API через OAuth2 без PRAW. Порт MCP-сервера Arindam200/reddit-mcp на shell-скрипты по образцу yandex-metrika / yandex-search-api.
Config
Нужно зарегистрировать script-app на https://www.reddit.com/prefs/apps и заполнить config/.env. Подробная инструкция: config/README.md.
Минимум для read-only:
REDDIT_CLIENT_ID,REDDIT_CLIENT_SECRETREDDIT_USER_AGENT(обязателен по правилам Reddit)
Для write/me дополнительно:
REDDIT_USERNAME,REDDIT_PASSWORD— владелец script-app (без 2FA)REDDIT_ENABLE_WRITE=1+--confirmна каждой write-команде
Philosophy
1. Cache-first — токен, юзеры, сабреддиты, листинги кешируются по детерминированному ключу. 2. Context window hygiene — stdout ≤ 30 строк, полный JSON в cache/listings/<hash>.json. 3. Auth auto-detect — если в .env есть REDDIT_USERNAME+REDDIT_PASSWORD, скилл идёт в user mode (grant_type=password); иначе — app-only (grant_type=client_credentials). 4. Двойная защита write — два уровня:
- Без
REDDIT_ENABLE_WRITE=1в.envwrite-скрипты отказывают сразу с понятной ошибкой (никакого dry-run — это «ворота» уровня окружения). - С
REDDIT_ENABLE_WRITE=1, но без--confirm— dry-run: печатают что бы сделали, но не отправляют запрос. - Только при
REDDIT_ENABLE_WRITE=1и--confirmкоманда реально идёт на Reddit.
5. Rate-limit от заголовков — читаем x-ratelimit-remaining, x-ratelimit-reset, Retry-After. На 429 один retry если ожидание ≤ 60s.
Workflow
STOP! Перед любым запросом
1. Проверь, что credentials настроены:
sh scripts/auth_check.shДолжен вывести OK: app-only token works (или OK: user token works).
2. Если нужна write-операция:
- Убедись, что в
.envестьREDDIT_USERNAME+REDDIT_PASSWORD+REDDIT_ENABLE_WRITE=1
(без REDDIT_ENABLE_WRITE=1 команда сразу падает с ошибкой — это намеренно)
- Сначала запусти команду без
--confirm— увидишь dry-run - Только после ревью пользователя — добавляй
--confirm
3. Имя сабреддита/юзера можно передавать с префиксом или без: r/python ≡ python, u/spez ≡ spez.
Scripts
Auth & identity
| Script | Endpoint | Mode | Description |
|---|---|---|---|
auth_check.sh | GET /r/all/new?limit=1 | any | Sanity-check токена |
me.sh | GET /api/v1/me | user only | Личный профиль (карма, имя) |
Read
| Script | Endpoint | Description |
|---|---|---|
user_info.sh --username U | /user/{u}/about | Профиль юзера |
user_posts.sh --username U | /user/{u}/submitted | Посты юзера |
user_comments.sh --username U | /user/{u}/comments | Комментарии юзера |
subreddit_info.sh --subreddit S | /r/{s}/about | Метаданные сабреддита |
subreddit_stats.sh --subreddit S | /r/{s}/about + /about/rules + /about/moderators | Расширенные метрики + правила + модераторы |
subreddit_popular.sh | /subreddits/popular | Популярные сабреддиты (Reddit «trending») |
subreddit_top.sh --subreddit S | /r/{s}/top (+ /comments/{id} per-post при --include-comments) | Топ-посты сабреддита; опционально с комментариями к каждому |
search.sh --query "..." | /search или /r/{s}/search | Поиск по Reddit (опц. --subreddit) |
submission.sh --id I или --url U | /comments/{id} | Пост + топ-комментарии |
Общие read-флаги: --limit N, --no-cache, --time T (где применимо), --sort S (где применимо).
Write (опасные — двойной предохранитель)
| Script | Endpoint | Description |
|---|---|---|
| `post_create.sh --subreddit S --title T (--content C \ | --url U)` | POST /api/submit |
comment_reply.sh --parent-id ID --content C | POST /api/comment | Ответить на пост (t3_*) или комментарий (t1_*) |
subreddit_subscribe.sh --subreddit S [--unsubscribe] | POST /api/subscribe | Подписка / отписка |
Все write-команды требуют --confirm и REDDIT_ENABLE_WRITE=1 в .env. Поведение:
REDDIT_ENABLE_WRITEне установлен → отказ с ошибкой (envelope-уровень).REDDIT_ENABLE_WRITE=1, но без--confirm→ dry-run (печать, без сети).- Оба условия выполнены → реальный POST.
Cache layout
cache/
├── token.json # OAuth access token + expires_at
├── users/<username>.json # /user/{u}/about
├── subreddits/<sub>.json # /r/{sub}/about
├── subreddits/<sub>.rules.json
├── subreddits/<sub>.moderators.json
├── listings/<hash>.json # листинги (search, top, posts, comments, popular)
└── me.json # /api/v1/meПоиск по кешу: grep -r "term" cache/ или rg "term" cache/.
Examples
# 1) Profile of a user
sh scripts/user_info.sh --username spez
# 2) Top posts in r/Python this week
sh scripts/subreddit_top.sh --subreddit python --time week --limit 25
# 3) Search "machine learning" in r/learnprogramming, sorted by new
sh scripts/search.sh --query "machine learning" --subreddit learnprogramming \
--sort new --time month --limit 20
# 4) Open a submission by URL (with comments)
sh scripts/submission.sh \
--url "https://www.reddit.com/r/Python/comments/abc123/some_title/" \
--limit-comments 50
# 5) DRY-RUN of a comment reply (requires REDDIT_ENABLE_WRITE=1; without --confirm only prints)
REDDIT_ENABLE_WRITE=1 sh scripts/comment_reply.sh \
--parent-id t3_abc123 --content "Looks great!"
# 6) Real reply (after dry-run review)
REDDIT_ENABLE_WRITE=1 sh scripts/comment_reply.sh \
--parent-id t3_abc123 --content "Looks great!" --confirm
# 7) Top posts WITH comments per post (1 API call per post — use sparingly)
sh scripts/subreddit_top.sh --subreddit Python --time week --limit 5 \
--include-comments --comments-per-post 25Rate limits
Reddit OAuth: ~60 req/min для app-only, ~600/10min для user-mode (зависит от истории аккаунта). Скилл читает Reddit-специфичные заголовки x-ratelimit-* и стандартный Retry-After. На 429 делает один retry если ожидание ≤ 60s, иначе fail с понятной ошибкой.
Tests
sh scripts/tests/run.shNo-network, проверяют:
- парсинг submission URL → id
- детерминированность cache_key
- корректность авто-выбора режима auth
- что write-команды без
REDDIT_ENABLE_WRITEили без--confirmничего не отправляют
Differences from upstream
Оригинальный reddit-mcp использует PRAW + Python и зашит как MCP-сервер. Здесь — shell-скрипты, прямые curl-запросы, кеш в директории скилла. AI-driven анализ (engagement insights, optimal posting time) намеренно перенесён на сторону Claude — он сам интерпретирует JSON в cache/.
Документация
config/.env
cache/*
!cache/.gitkeep
# =====================================================================
# reddit-skill — credentials
# =====================================================================
# See config/README.md for the full setup walkthrough.
#
# Reddit OAuth2 supports two modes; the skill auto-detects:
# - app-only (read): CLIENT_ID + CLIENT_SECRET → grant_type=client_credentials
# - user (write): + REDDIT_USERNAME + REDDIT_PASSWORD → grant_type=password
#
# Required for any operation:
REDDIT_CLIENT_ID=your_client_id
REDDIT_CLIENT_SECRET=your_client_secret
# Required by Reddit's API rules. Format suggested by Reddit:
# <platform>:<app-id>:<version> (by /u/<your-username>)
# Example: "claude-skill:reddit-skill:1.0.0 (by /u/yourname)"
REDDIT_USER_AGENT="claude-skill:reddit-skill:1.0.0 (by /u/your_username)"
# Optional — only needed for me.sh and write operations (post_create,
# comment_reply, subreddit_subscribe). The Reddit account must be the
# OWNER of the script-type app (CLIENT_ID/SECRET above).
# REDDIT_USERNAME=your_username
# REDDIT_PASSWORD=your_password
# Optional — explicit write enable. WRITE operations also require --confirm
# on the command line. Both must be set; otherwise dry-run only.
# REDDIT_ENABLE_WRITE=1
Настройка скилла reddit-skill
Скилл общается с Reddit API напрямую через OAuth2 — никакого PRAW. Поддерживает два режима, скилл выбирает их автоматически:
| Режим | Grant | Что нужно | Что доступно |
|---|---|---|---|
| app-only | client_credentials | CLIENT_ID + CLIENT_SECRET | Все read-операции |
| user | password | + REDDIT_USERNAME + REDDIT_PASSWORD | Read + write + me |
Для write-команд — двойной предохранитель: REDDIT_ENABLE_WRITE=1 в .env и явный --confirm в командной строке.
---
Шаг 1: Получите Reddit OAuth-приложение
С 2024 Reddit изменил порядок выдачи API-доступа. Старый «зашёл в /prefs/apps → нажал create app → получил creds» работает не у всех — для новых аккаунтов часто требуется ручное одобрение через форму Reddit Help. Поэтому сначала проверь, не подойдёт ли что-то из уже существующего, и только потом подавай заявку.
Алгоритм (по приоритету)
Вариант A — у тебя уже есть Reddit-приложение
Используй его. Скиллу нужны только client_id + client_secret от любого script или web app приложения — название и исходное назначение неважны.
1. Открой https://old.reddit.com/prefs/apps 2. Найди существующее приложение в списке 3. Под названием будет тип в скобках (personal use script / web app) и серая короткая строка ~14 символов — это `client_id` 4. Чуть ниже поле `secret` — это `client_secret` (если скрыто, нажми «edit») 5. Переходи к Шагу 2
Не удаляй старые приложения, даже если они выглядят неактуальными. По свежим жалобам в r/redditdev после удаления старого app многие не могут получить новый — попадают в новый approval-flow.
>
installed appне подходит — у него нетclient_secret. В этом случае создавай новое (B) или подавай заявку (C).
Вариант B — попробовать создать новое через /prefs/apps
Иногда форма всё ещё работает — особенно если аккаунт не свежий и не помечен анти-фродом.
1. Открой https://old.reddit.com/prefs/apps (старый интерфейс стабильнее нового) 2. Жми «create another app...» внизу 3. Заполни:
- Name:
reddit-skill(или любое) - Type: см. таблицу ниже
- Description: пусто или короткое нейтральное описание (см. подсказки ниже про формулировки)
- About URL: можно пусто
- Redirect URI:
http://localhost:8080(формально требуется; для read-only не используется)
4. Пройди капчу и сразу же жми `create app` (токен капчи живёт ~2 минуты)
Если форма принимает запрос — Reddit редиректит на список приложений и показывает новую карточку. Забирай client_id + secret и переходи к Шагу 2.
Если форма молчит / редиректит на Responsible Builder Policy / показывает «You cannot create any more applications» — это новый approval-flow. Переходи к варианту C.
Вариант C — заявка через Request API Access
Reddit официально пишет: для нового Data API доступа нужно подать заявку через форму Reddit Help, и только после одобрения возвращаешься в /prefs/apps создавать приложение.
1. Открой форму Request API Access: https://support.reddithelp.com/hc/en-us/requests/new 2. В поле «What do you need assistance with?» выбери «Request API Access» 3. Заполни описание use case аккуратно — формулировка решает (см. шаблоны ниже) 4. Жди одобрения (обычно несколько дней — недели) 5. После одобрения возвращайся к Варианту B и создавай приложение
Какой тип приложения выбрать?
| Тип | Что выдаёт | client_credentials (read) | password grant (write) | Функций скилла |
|---|---|---|---|---|
| `script` | client_id + secret | ✅ | ✅ | все 14 |
| `web app` | client_id + secret | ✅ | ❌ | 10 read-операций |
installed app | только client_id | ❌ (нет secret) | ❌ | не подходит |
Рекомендация: ставь script. Если форма не пускает или одобрение получено только под non-personal use — web app тоже подойдёт для read-режима (это 90% полезности скилла).
Формулировка use case (важно)
Reddit разделяет non-commercial и commercial use, и определение коммерческого довольно широкое: использование «by a business» / «on behalf of a business» / «as part of a monetized product or service» уже считается commercial. AI/ML training вообще запрещено без отдельного контракта.
Этот скилл — read-only research helper: тянет публичные посты/комментарии, кеширует кратковременно, ничего не публикует, не голосует, не пишет в DM, не используется для тренировки моделей и не перепродаёт данные. Это укладывается в non-commercial профиль, который Reddit одобряет охотнее.
Что писать в Description / в заявке Request API Access:
A private, read-only research assistant for monitoring a small set of public
subreddits and producing internal summaries of public discussions.
The app does NOT:
- post, comment, vote, or send messages
- automate any user-facing interaction
- scrape outside of the official Data API
- train or fine-tune AI/ML models on Reddit content
- redistribute or resell Reddit data
The app uses a unique User-Agent, OAuth credentials, short-lived response
caching for operational use only, and removes cached content during routine
cleanup to comply with Reddit's data retention/deletion requirements.Если у тебя уже есть существующий Reddit app под другой проект — добавь это в заявку:
I already have an existing Reddit API app and would like to register a
separate new read-only app for an isolated internal research workflow.Чего НЕ писать:
- «AI agent для генерации идей для канала» / «для блога» — звучит как контент-фарминг
- «Generates content for…» — Reddit чувствителен к ML/training-кейсам
- Любые упоминания монетизации, B2B-продукта, SaaS, подписок, перепродажи
- «Trains a model on…» — это явный трип-вайр, прямо запрещено
Если use case действительно коммерческий (канал/консалтинг монетизируется) — Reddit ожидает, что ты заявишь это явно через отдельный канал: https://support.reddithelp.com/hc/en-us/requests/new (выбрать commercial-вариант). Для personal exploration / private research / non-commercial — обычная Request API Access форма.
Если форма /prefs/apps не сабмитится
Симптомы: жмёшь create app — ничего не происходит, URL не меняется, или редиректит на Responsible Builder Policy.
Прежде чем подавать заявку через Вариант C — проверь техническое:
1. Hard reload (⌘+Shift+R / Ctrl+Shift+R) — заполни заново и пройди капчу ровно перед кликом 2. Инкогнито-режим — расширения (uBlock Origin, Privacy Badger, AdGuard) ломают Reddit anti-bot. В инкогнито их нет. 3. Старый Reddit явно — https://old.reddit.com/prefs/apps 4. Без VPN — Reddit банит часть VPN-IP с определённых регионов 5. Приложение случайно не создалось — открой /prefs/apps без /create и посмотри список. Один из «молчаливых» сабмитов мог сработать 6. DevTools → Network → XHR — нажми create, посмотри запрос. 4xx-ответ покажет реальную причину
Если после всего этого форма всё равно показывает policy-страницу или ругается «cannot create any more applications» — это approval-flow, технически не обходится. Подавай заявку через Вариант C.
Чего точно не делать
- ❌ Не использовать reverse-engineered токены официальных мобильных приложений Reddit (в r/redditdev иногда советуют — это нарушение ToS, бан прилетает быстро)
- ❌ Не шарить чужие creds
- ❌ Не пытаться обходить approval-flow техническими средствами
- ❌ Не использовать дефолтный User-Agent из браузера (Reddit банит за это)
---
Шаг 2: Заполните .env
cp config/.env.example config/.envОткройте config/.env и подставьте значения:
REDDIT_CLIENT_ID=abcDEF123_xyz
REDDIT_CLIENT_SECRET=ваш_secret
REDDIT_USER_AGENT="claude-skill:reddit-skill:1.0.0 (by /u/yourname)"REDDIT_USER_AGENT обязателен по правилам Reddit API. Шаблон: <platform>:<app-id>:<version> (by /u/<username>). Не используйте дефолтные строки браузеров — Reddit может банить.
Опционально: write/me
Чтобы получить доступ к me.sh, post_create.sh, comment_reply.sh, subreddit_subscribe.sh, добавьте:
REDDIT_USERNAME=yourname
REDDIT_PASSWORD=your_password
REDDIT_ENABLE_WRITE=1Аккаунт должен быть владельцем script-app (того, чьи client_id/secret указаны выше). Если у аккаунта включена 2FA, password grant не сработает — отключите 2FA либо используйте read-only режим.
Поведение write-скриптов:
- Без REDDIT_ENABLE_WRITE=1 — сразу падают с ошибкой (envelope-уровень, защита от случайной активации write).- СREDDIT_ENABLE_WRITE=1, но без--confirm— dry-run: печатают что бы отправили, но не идут в сеть.
- Только с обоими — реальный POST.
---
Шаг 3: Проверка
App-only режим:
sh scripts/auth_check.shДолжен вывести OK: app-only token works.
User режим:
sh scripts/me.shДолжен вывести имя пользователя, карму и т. п.
---
Лимиты
Reddit OAuth API:
- Базовый: ~60 запросов/минуту
- При наличии user-токена: до ~600/10мин (зависит от истории аккаунта)
Скилл читает x-ratelimit-remaining, x-ratelimit-reset, Retry-After из заголовков и при 429 ждёт Retry-After (≤ 60s) и делает один retry.
Подробнее: https://www.reddit.com/wiki/api
---
Срок жизни токена
Access token живёт ~3600 секунд. Скрипты автоматически рефрешат его через тот же grant, как только до истечения остаётся <300 секунд. Кеш — cache/token.json.
---
Troubleshooting
401 Unauthorized от auth_check.sh
- Неверные
CLIENT_ID/CLIENT_SECRET(проверьте, чтоclient_id— это короткая строка под названием приложения, а не имя приложения) - Тип приложения — не
script. Уweb appиinstalled appдругой OAuth flow
401 invalid_grant при password grant
- Неправильные
REDDIT_USERNAME/REDDIT_PASSWORD - На аккаунте включена 2FA — отключите либо используйте app-only режим
- Аккаунт не является владельцем приложения
429 Too Many Requests
- Превышен лимит. Скилл делает один retry, если
Retry-After ≤ 60s. При больших значениях — fail с текстом из заголовка. Подождите указанное время.
403 Forbidden от write-команд
- Сабреддит закрыт/требует флера/не пускает аккаунт-новичок
- На сабреддите бан/мут
Write-команды ничего не делают / падают
- `Write is disabled` ошибка — не задан
REDDIT_ENABLE_WRITE=1в.env. Это намеренная защита: чтобы вообще включить write-режим, нужно явно поставить переменную. - Dry-run без сетевого запроса —
REDDIT_ENABLE_WRITE=1стоит, но не передан--confirm. Это ожидаемое поведение: команда печатает что бы отправила и завершается. - Чтобы реально отправить запрос — нужны оба условия одновременно.
/prefs/apps редиректит на Responsible Builder Policy
- Это новый approval-flow, а не баг формы. Технически не обходится.
- Подавай заявку через https://support.reddithelp.com/hc/en-us/requests/new → «Request API Access». Шаблон описания — в Шаге 1 → Вариант C.
- Если у тебя уже есть рабочий старый app — используй его creds, заявка не нужна.
You cannot create any more applications
- Reddit стал жёстче к новым/малоактивным аккаунтам и в принципе к новым OAuth-приложениям. Иногда показывает эту ошибку, даже если у аккаунта 0 приложений.
- Не удаляй имеющиеся приложения — после удаления получить новое сложнее.
- Решение — заявка через Reddit Help (Вариант C в Шаге 1).
---
Дополнительно
- Reddit API docs: https://www.reddit.com/dev/api/
- OAuth2 wiki: https://github.com/reddit-archive/reddit/wiki/OAuth2
- API rules / User-Agent: https://github.com/reddit-archive/reddit/wiki/API
- Reddit Data API Terms: https://www.redditinc.com/policies/data-api-terms
- Developer Terms: https://www.redditinc.com/policies/developer-terms
- Responsible Builder Policy (что Reddit хочет видеть в use case): https://support.reddithelp.com/hc/en-us/articles/42728983564564-Responsible-Builder-Policy
- Запрос API Access (non-commercial): https://support.reddithelp.com/hc/en-us/requests/new — выбрать «Request API Access»
- Запрос commercial Data API: https://support.reddithelp.com/hc/en-us/requests/new — отдельный commercial-вариант
#!/bin/sh
# auth_check.sh — verify that OAuth credentials work in app-only mode.
# Calls GET https://oauth.reddit.com/r/all/new?limit=1 with a fresh Bearer.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
OUT="$REDDIT_TMPDIR/reddit_auth_check.$$.json"
trap 'rm -f "$OUT"' EXIT
reddit_get "/r/all/new" "$OUT" --data-urlencode "limit=1"
# Sanity-check shape
_F="$OUT" python3 - <<'PY' || die "Token call returned unexpected payload" "$(cat "$OUT" | head -c 500)"
import json, os, sys
with open(os.environ["_F"]) as f:
d = json.load(f)
if not (isinstance(d, dict) and d.get("kind") == "Listing"):
sys.exit(1)
PY
echo "OK: ${REDDIT_AUTH_MODE} token works"
echo " User-Agent: $REDDIT_USER_AGENT"
echo " Endpoint : $REDDIT_OAUTH_BASE"
#!/bin/sh
# comment_reply.sh — reply to a post (t3_*) or a comment (t1_*).
# Usage: comment_reply.sh --parent-id <t1_xxx | t3_xxx> --content "..." --confirm
#
# Double safeguard: REDDIT_ENABLE_WRITE=1 + --confirm.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
PARENT=""; CONTENT=""; CONFIRM=""
while [ $# -gt 0 ]; do
case "$1" in
--parent-id|-p) PARENT="$2"; shift 2 ;;
--content|-c) CONTENT="$2"; shift 2 ;;
--confirm) CONFIRM=1; shift ;;
*) shift ;;
esac
done
[ -n "$PARENT" ] || die "Usage: comment_reply.sh --parent-id <t1_xxx | t3_xxx> --content \"...\" --confirm"
[ -n "$CONTENT" ] || die "--content is required"
# Validate fullname format
case "$PARENT" in
t1_*|t3_*) : ;;
*)
# If user passed bare base36 id, assume submission (t3_)
_id=$(parse_submission_id "$PARENT") && PARENT="t3_${_id}" || \
die "--parent-id must be t1_<id> (comment) or t3_<id> (post). Got: $PARENT"
;;
esac
ACTION="reply to ${PARENT} (${#CONTENT} chars)"
if ! require_write_enabled "$CONFIRM" "$ACTION"; then
echo " parent : ${PARENT}"
echo " body : ${CONTENT}"
exit 0
fi
OUT="$REDDIT_TMPDIR/reddit_comment.$$.json"
trap 'rm -f "$OUT"' EXIT
reddit_post "/api/comment" "$OUT" \
--data-urlencode "thing_id=${PARENT}" \
--data-urlencode "text=${CONTENT}" \
--data-urlencode "api_type=json"
_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f)
j = d.get("json", {}) if isinstance(d, dict) else {}
errs = j.get("errors") or []
if errs:
print("ERRORS:")
for e in errs:
print(f" - {e}")
raise SystemExit(2)
things = (j.get("data") or {}).get("things") or []
if not things:
print("(no thing returned)")
raise SystemExit
t = things[0].get("data", {})
print("Replied:")
print(f" id : {t.get('id','?')}")
print(f" name : {t.get('name','?')}")
print(f" permalink : https://www.reddit.com{t.get('permalink','')}")
PY
#!/bin/sh
# Common functions for reddit-skill
# POSIX sh — no bashisms (cloud-sandbox compatible)
#
# Public API (sourced by other scripts):
# load_config — read .env, pick auth mode (app-only|user)
# reddit_token — get a valid access token (cached, auto-refresh)
# reddit_get PATH OUTFILE [-d k=v ...] — authenticated GET, body to OUTFILE
# reddit_post PATH OUTFILE [-d k=v ...] — authenticated POST, body to OUTFILE
# cache_key STRING — deterministic 32-bit hex from input
# require_write_enabled — fail unless REDDIT_ENABLE_WRITE=1 and --confirm
# parse_submission_id ARG — extract base36 id from URL or raw id
# require_user_mode — fail unless mode=user
# print_listing_summary FILE [N] — print first N rows of cached JSON listing
#
# Variables exported by load_config:
# REDDIT_AUTH_MODE — "app-only" | "user"
# REDDIT_OAUTH_BASE — "https://oauth.reddit.com"
# REDDIT_TOKEN_URL — "https://www.reddit.com/api/v1/access_token"
# REDDIT_USER_AGENT — required for all requests
# REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET — sourced from .env
set -e
# --- Resolve dirs ----------------------------------------------------
if [ -z "${REDDIT_SCRIPT_DIR:-}" ]; then
REDDIT_SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
fi
if [ -z "${REDDIT_SKILL_DIR:-}" ]; then
REDDIT_SKILL_DIR="$(cd "$REDDIT_SCRIPT_DIR/.." && pwd)"
fi
REDDIT_CONFIG_DIR="${REDDIT_CONFIG_DIR:-$REDDIT_SKILL_DIR/config}"
REDDIT_CACHE_DIR="${REDDIT_CACHE_DIR:-$REDDIT_SKILL_DIR/cache}"
REDDIT_OAUTH_BASE="https://oauth.reddit.com"
REDDIT_TOKEN_URL="https://www.reddit.com/api/v1/access_token"
REDDIT_TMPDIR="${TMPDIR:-/tmp}"
mkdir -p "$REDDIT_TMPDIR" "$REDDIT_CACHE_DIR"
# --- Error helper ----------------------------------------------------
die() {
echo "[reddit-skill] $1" >&2
[ -n "${2:-}" ] && echo "$2" >&2
echo "" >&2
echo "See: config/README.md" >&2
exit 1
}
# --- Config loader ---------------------------------------------------
load_config() {
_env_file="$REDDIT_CONFIG_DIR/.env"
if [ -f "$_env_file" ]; then
# shellcheck disable=SC1090
. "$_env_file"
fi
[ -n "${REDDIT_CLIENT_ID:-}" ] || die "REDDIT_CLIENT_ID not set in config/.env"
[ -n "${REDDIT_CLIENT_SECRET:-}" ] || die "REDDIT_CLIENT_SECRET not set in config/.env"
if [ -z "${REDDIT_USER_AGENT:-}" ]; then
die "REDDIT_USER_AGENT not set" \
"Reddit requires a custom User-Agent. See config/README.md."
fi
# Auth mode auto-detect: presence of username+password → user mode
if [ -n "${REDDIT_USERNAME:-}" ] && [ -n "${REDDIT_PASSWORD:-}" ]; then
REDDIT_AUTH_MODE="user"
else
REDDIT_AUTH_MODE="app-only"
fi
export REDDIT_AUTH_MODE
}
# --- Token cache -----------------------------------------------------
_token_cache_file() {
echo "$REDDIT_CACHE_DIR/token.json"
}
# Echo cached token if not expired (30s safety margin), else nothing.
_get_cached_token() {
_cf=$(_token_cache_file)
[ -f "$_cf" ] || return 0
_MODE="$REDDIT_AUTH_MODE" _CACHE="$_cf" python3 - <<'PY' 2>/dev/null
import json, os, time
try:
with open(os.environ["_CACHE"]) as f:
d = json.load(f)
if d.get("mode") != os.environ["_MODE"]:
raise SystemExit
if d.get("expires_at", 0) - time.time() <= 30:
raise SystemExit
print(d.get("access_token", ""))
except SystemExit:
pass
except Exception:
pass
PY
}
_save_token() {
_tok="$1"
_exp="$2"
_scope="$3"
_cf=$(_token_cache_file)
_old_umask=$(umask)
umask 077
_tmp="$REDDIT_CACHE_DIR/.token.tmp.$$"
_T="$_tok" _E="$_exp" _S="$_scope" _M="$REDDIT_AUTH_MODE" _F="$_tmp" python3 - <<'PY'
import json, os
d = {
"access_token": os.environ["_T"],
"expires_at": int(os.environ["_E"]),
"scope": os.environ["_S"],
"mode": os.environ["_M"],
}
with open(os.environ["_F"], "w") as f:
json.dump(d, f)
PY
mv "$_tmp" "$_cf"
umask "$_old_umask"
}
# Issue fresh token via OAuth POST. Echoes token on stdout.
_token_issue() {
_now=$(date +%s)
_resp_file="$REDDIT_TMPDIR/reddit_token_resp.$$.json"
_hdr_file="$REDDIT_TMPDIR/reddit_token_hdr.$$.txt"
# shellcheck disable=SC2064
trap "rm -f '$_resp_file' '$_hdr_file'" EXIT INT TERM
case "$REDDIT_AUTH_MODE" in
app-only)
_body="grant_type=client_credentials"
;;
user)
# urlencode user+password (POSIX sh — minimal; rely on python3)
_body=$(REDDIT_USERNAME="$REDDIT_USERNAME" REDDIT_PASSWORD="$REDDIT_PASSWORD" \
python3 -c '
import os, urllib.parse
u = urllib.parse.quote(os.environ["REDDIT_USERNAME"], safe="")
p = urllib.parse.quote(os.environ["REDDIT_PASSWORD"], safe="")
print(f"grant_type=password&username={u}&password={p}")
')
;;
*) die "Unknown REDDIT_AUTH_MODE: $REDDIT_AUTH_MODE" ;;
esac
_status=$(curl -s -o "$_resp_file" -D "$_hdr_file" -w '%{http_code}' \
-X POST "$REDDIT_TOKEN_URL" \
-u "$REDDIT_CLIENT_ID:$REDDIT_CLIENT_SECRET" \
-A "$REDDIT_USER_AGENT" \
--data "$_body")
if [ "$_status" != "200" ]; then
_err=$(cat "$_resp_file" 2>/dev/null)
rm -f "$_resp_file" "$_hdr_file"
trap - EXIT INT TERM
die "Token request failed (HTTP $_status, mode=$REDDIT_AUTH_MODE)" "$_err"
fi
# Parse {"access_token":"...","token_type":"bearer","expires_in":N,"scope":"..."}
_result=$(_NOW="$_now" _F="$_resp_file" python3 - <<'PY'
import json, os, sys
try:
with open(os.environ["_F"]) as f:
d = json.load(f)
except Exception as e:
print(f"PARSE:{e}")
sys.exit(0)
tok = d.get("access_token", "")
exp_in = d.get("expires_in", 3600)
scope = d.get("scope", "")
err = d.get("error", "")
if not tok:
print(f"ERR:{err or json.dumps(d)[:200]}")
sys.exit(0)
print(f"{tok}|{int(os.environ['_NOW'])+int(exp_in)}|{scope}")
PY
)
rm -f "$_resp_file" "$_hdr_file"
trap - EXIT INT TERM
case "$_result" in
PARSE:*) die "Token response parse error: ${_result#PARSE:}" ;;
ERR:*) die "Token response error: ${_result#ERR:}" ;;
esac
_tok=$(printf '%s' "$_result" | cut -d'|' -f1)
_exp=$(printf '%s' "$_result" | cut -d'|' -f2)
_scope=$(printf '%s' "$_result" | cut -d'|' -f3-)
_save_token "$_tok" "$_exp" "$_scope"
printf '%s' "$_tok"
}
reddit_token() {
_cached=$(_get_cached_token)
if [ -n "$_cached" ]; then
printf '%s' "$_cached"
return 0
fi
_token_issue
}
# --- HTTP wrappers ---------------------------------------------------
#
# reddit_get PATH OUTFILE [extra curl args ...]
# reddit_post PATH OUTFILE [extra curl args ...]
#
# OUTFILE receives the response body.
# Honors x-ratelimit-* headers and Retry-After on 429 (one retry if reset ≤ 60s).
# On 401 invalidates cached token and retries once.
_reddit_request() {
_method="$1" # GET | POST
_path="$2"
_out="$3"
shift 3
_tok=$(reddit_token)
[ -n "$_tok" ] || die "Failed to obtain access token"
_hdr="$REDDIT_TMPDIR/reddit_hdr.$$.txt"
_url="${REDDIT_OAUTH_BASE}${_path}"
# Append raw_json=1 to all calls — Reddit returns un-HTML-escaped strings
case "$_url" in
*\?*) _url="${_url}&raw_json=1" ;;
*) _url="${_url}?raw_json=1" ;;
esac
if [ "$_method" = "GET" ]; then
_status=$(curl -s -G -o "$_out" -D "$_hdr" -w '%{http_code}' \
-A "$REDDIT_USER_AGENT" \
-H "Authorization: Bearer $_tok" \
"$@" \
"$_url")
else
_status=$(curl -s -X "$_method" -o "$_out" -D "$_hdr" -w '%{http_code}' \
-A "$REDDIT_USER_AGENT" \
-H "Authorization: Bearer $_tok" \
"$@" \
"$_url")
fi
case "$_status" in
2[0-9][0-9])
rm -f "$_hdr"
return 0
;;
401)
# Token might be stale — invalidate cache, retry once.
if [ -z "${_REDDIT_RETRY_401:-}" ]; then
rm -f "$(_token_cache_file)"
rm -f "$_hdr"
_REDDIT_RETRY_401=1 _reddit_request "$_method" "$_path" "$_out" "$@"
return $?
fi
_err=$(cat "$_out" 2>/dev/null)
rm -f "$_hdr"
die "HTTP 401 Unauthorized after token refresh" "$_err"
;;
429)
_retry=$(grep -i '^Retry-After:' "$_hdr" | sed 's/[^0-9]//g' | head -1)
_reset=$(grep -i '^x-ratelimit-reset:' "$_hdr" | sed 's/[^0-9.]//g' | head -1 | cut -d. -f1)
_wait="$_retry"
[ -z "$_wait" ] && _wait="$_reset"
rm -f "$_hdr"
if [ -z "${_REDDIT_RETRY_429:-}" ] && [ -n "$_wait" ] && [ "$_wait" -le 60 ] 2>/dev/null; then
echo "Rate limited (429). Waiting ${_wait}s..." >&2
sleep "$_wait"
_REDDIT_RETRY_429=1 _reddit_request "$_method" "$_path" "$_out" "$@"
return $?
fi
_err=$(cat "$_out" 2>/dev/null | head -c 500)
die "HTTP 429 Too Many Requests (wait=${_wait:-?}s)" "$_err"
;;
*)
_err=$(cat "$_out" 2>/dev/null | head -c 1000)
rm -f "$_hdr"
die "HTTP $_status from $_path" "$_err"
;;
esac
}
reddit_get() { _reddit_request "GET" "$@"; }
reddit_post() { _reddit_request "POST" "$@"; }
# --- Cache helpers ---------------------------------------------------
# cache_key STRING — 32-bit hex hash via cksum
cache_key() {
printf '%s' "$1" | cksum | awk '{printf "%08x", $1}'
}
# --- Write-mode guard ------------------------------------------------
#
# Each write-script must call:
# require_write_enabled "$CONFIRM_FLAG" "human-readable action"
# where CONFIRM_FLAG is "1" if --confirm was passed and "" otherwise.
#
# Behavior (matches docs in SKILL.md / config/README.md):
# - REDDIT_ENABLE_WRITE != 1 → die with error (envelope-level gate)
# - REDDIT_AUTH_MODE != user → die with error (need password grant)
# - CONFIRM_FLAG empty → returns 1 — caller prints dry-run and exits 0
# - all guards pass → returns 0 — caller proceeds with real POST
require_write_enabled() {
_confirm="$1"
_action="$2"
if [ "${REDDIT_ENABLE_WRITE:-}" != "1" ]; then
die "Write is disabled" \
"Set REDDIT_ENABLE_WRITE=1 in config/.env to allow ${_action}."
fi
if [ "$REDDIT_AUTH_MODE" != "user" ]; then
die "Write requires user mode (REDDIT_USERNAME + REDDIT_PASSWORD)" \
"Currently in ${REDDIT_AUTH_MODE}. ${_action} is not possible without a user token."
fi
if [ -z "$_confirm" ]; then
echo "DRY-RUN: ${_action}" >&2
echo " No request was sent. Pass --confirm to actually execute." >&2
return 1
fi
return 0
}
require_user_mode() {
[ "$REDDIT_AUTH_MODE" = "user" ] || \
die "This operation requires user mode (REDDIT_USERNAME + REDDIT_PASSWORD)" \
"Currently in ${REDDIT_AUTH_MODE}."
}
# --- Submission id parsing -------------------------------------------
#
# parse_submission_id ARG → echoes base36 id (without t3_ prefix)
# Accepts:
# abc123 → abc123
# t3_abc123 → abc123
# https://www.reddit.com/r/sub/comments/abc123/title/ → abc123
# https://www.reddit.com/r/sub/comments/abc123/ → abc123
# https://redd.it/abc123 → abc123
parse_submission_id() {
_ARG="$1" python3 - <<'PY'
import os, re, sys
arg = os.environ["_ARG"].strip()
if not arg:
sys.exit(1)
# Try URL paths
m = re.search(r"/comments/([a-z0-9]{4,12})(?:/|$)", arg, re.I)
if m:
print(m.group(1).lower())
sys.exit(0)
m = re.search(r"redd\.it/([a-z0-9]{4,12})(?:/|$|\?)", arg, re.I)
if m:
print(m.group(1).lower())
sys.exit(0)
# Strip t3_ prefix
if arg.startswith("t3_"):
arg = arg[3:]
if re.fullmatch(r"[a-z0-9]{4,12}", arg, re.I):
print(arg.lower())
sys.exit(0)
sys.exit(1)
PY
}
# --- Output helpers --------------------------------------------------
#
# print_listing_summary FILE [N]
# Pretty-prints first N items of a Reddit listing JSON. Default N=10.
# Hard-capped at REDDIT_PRINT_CAP (default 8) to keep stdout ≤ 30 lines —
# the fetched listing on disk may be larger; only the print is capped.
print_listing_summary() {
_F="$1" _N="${2:-8}" _CAP="${REDDIT_PRINT_CAP:-8}" python3 - <<'PY'
import json, os, sys
n_req = int(os.environ.get("_N", "8"))
cap = int(os.environ.get("_CAP", "8"))
n = min(n_req, cap)
try:
with open(os.environ["_F"]) as f:
d = json.load(f)
except Exception as e:
print(f"(parse error: {e})")
sys.exit(0)
# Listing shape: {"kind":"Listing","data":{"children":[{"kind":"t3","data":{...}}, ...]}}
data = d.get("data") if isinstance(d, dict) else None
children = data.get("children") if isinstance(data, dict) else None
if not isinstance(children, list):
# Maybe it's an array of two listings (e.g. /comments/{id})
if isinstance(d, list) and d and isinstance(d[0], dict):
children = d[0].get("data", {}).get("children", [])
else:
print("(no listing children)")
sys.exit(0)
shown = 0
for c in children[:n]:
cd = c.get("data", {}) if isinstance(c, dict) else {}
kind = c.get("kind", "?") if isinstance(c, dict) else "?"
if kind == "t3": # post
title = (cd.get("title") or "").strip().replace("\n", " ")[:120]
sub = cd.get("subreddit", "")
score = cd.get("score", 0)
n_comm = cd.get("num_comments", 0)
author = cd.get("author", "")
url = "https://www.reddit.com" + cd.get("permalink", "")
print(f" [{score:>5} ↑ {n_comm:>4} 💬] r/{sub} by u/{author}")
print(f" {title}")
print(f" {url}")
elif kind == "t1": # comment
body = (cd.get("body") or "").strip().replace("\n", " ")[:160]
sub = cd.get("subreddit", "")
score = cd.get("score", 0)
author = cd.get("author", "")
link = "https://www.reddit.com" + cd.get("permalink", "")
print(f" [{score:>5} ↑] r/{sub} by u/{author}")
print(f" {body}")
print(f" {link}")
elif kind == "t5": # subreddit
name = cd.get("display_name_prefixed", cd.get("display_name", ""))
subs = cd.get("subscribers", 0)
active = cd.get("accounts_active") or cd.get("active_user_count") or 0
print(f" {name:<30} subs={subs:>9} active={active}")
else:
print(f" ({kind}) {cd.get('id','?')}")
shown += 1
total = len(children)
if total > shown:
suffix = ""
if n_req > cap and total > cap:
suffix = f" — print capped at {cap}, fetched {total}"
print(f" ... ({total - shown} more in cache file{suffix})")
PY
}
#!/bin/sh
# me.sh — current user identity. Requires user mode (password grant).
# GET /api/v1/me
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
require_user_mode
OUT="$REDDIT_CACHE_DIR/me.json"
reddit_get "/api/v1/me" "$OUT"
_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f)
print(f"Username : u/{d.get('name','?')}")
print(f"ID : {d.get('id','?')}")
print(f"Comment karma : {d.get('comment_karma', 0)}")
print(f"Link karma : {d.get('link_karma', 0)}")
print(f"Total karma : {d.get('total_karma', d.get('comment_karma',0)+d.get('link_karma',0))}")
print(f"Created (UTC) : {d.get('created_utc', '?')}")
print(f"Verified email : {d.get('has_verified_email', False)}")
print(f"Is mod : {d.get('is_mod', False)}")
PY
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# post_create.sh — submit a new post to a subreddit.
# Usage: post_create.sh --subreddit <name> --title "..." [--content "..." | --url "..."]
# [--flair-id <id>] [--nsfw] [--spoiler] --confirm
#
# Double safeguard:
# - REDDIT_ENABLE_WRITE=1 in config/.env
# - --confirm flag on the command line
# Without both → dry-run only (no network call).
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
SUB=""; TITLE=""; CONTENT=""; URL=""; FLAIR=""; NSFW=""; SPOILER=""; CONFIRM=""
while [ $# -gt 0 ]; do
case "$1" in
--subreddit|-s) SUB="$2"; shift 2 ;;
--title) TITLE="$2"; shift 2 ;;
--content|-c) CONTENT="$2"; shift 2 ;;
--url) URL="$2"; shift 2 ;;
--flair-id) FLAIR="$2"; shift 2 ;;
--nsfw) NSFW=1; shift ;;
--spoiler) SPOILER=1; shift ;;
--confirm) CONFIRM=1; shift ;;
*) shift ;;
esac
done
[ -n "$SUB" ] || die "Usage: post_create.sh --subreddit <name> --title \"...\" [--content \"...\" | --url \"...\"] --confirm"
[ -n "$TITLE" ] || die "--title is required"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
if [ -n "$URL" ] && [ -n "$CONTENT" ]; then
die "Pass either --url (link post) or --content (self post), not both"
fi
if [ -n "$URL" ]; then
KIND="link"
else
KIND="self"
fi
ACTION="post in r/${SUB} (kind=${KIND}, title=\"${TITLE}\")"
if ! require_write_enabled "$CONFIRM" "$ACTION"; then
# Dry-run mode — print what would be sent
echo " subreddit : r/${SUB}"
echo " kind : ${KIND}"
echo " title : ${TITLE}"
[ -n "$CONTENT" ] && echo " text : ${CONTENT}"
[ -n "$URL" ] && echo " url : ${URL}"
[ -n "$FLAIR" ] && echo " flair_id : ${FLAIR}"
[ -n "$NSFW" ] && echo " nsfw : true"
[ -n "$SPOILER" ] && echo " spoiler : true"
exit 0
fi
OUT="$REDDIT_TMPDIR/reddit_post_create.$$.json"
trap 'rm -f "$OUT"' EXIT
# Build form-data args
set -- --data-urlencode "sr=${SUB}" \
--data-urlencode "title=${TITLE}" \
--data-urlencode "kind=${KIND}" \
--data-urlencode "api_type=json" \
--data-urlencode "resubmit=true" \
--data-urlencode "sendreplies=true"
if [ -n "$CONTENT" ]; then
set -- "$@" --data-urlencode "text=${CONTENT}"
fi
if [ -n "$URL" ]; then
set -- "$@" --data-urlencode "url=${URL}"
fi
if [ -n "$FLAIR" ]; then
set -- "$@" --data-urlencode "flair_id=${FLAIR}"
fi
if [ -n "$NSFW" ]; then
set -- "$@" --data-urlencode "nsfw=true"
fi
if [ -n "$SPOILER" ]; then
set -- "$@" --data-urlencode "spoiler=true"
fi
reddit_post "/api/submit" "$OUT" "$@"
_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f)
j = d.get("json", {}) if isinstance(d, dict) else {}
errs = j.get("errors") or []
if errs:
print("ERRORS:")
for e in errs:
print(f" - {e}")
raise SystemExit(2)
data = j.get("data") or {}
print("Submitted:")
print(f" url : {data.get('url','?')}")
print(f" id : {data.get('id','?')}")
print(f" name : {data.get('name','?')}")
PY
#!/bin/sh
# search.sh — search posts site-wide or within a subreddit.
# Usage: search.sh --query "text" [--subreddit <name>] [--sort relevance|hot|new|top|comments]
# [--time hour|day|week|month|year|all] [--limit 25] [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
QUERY=""; SUB=""; SORT="relevance"; TIME="all"; LIMIT="25"; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--query|-q) QUERY="$2"; shift 2 ;;
--subreddit|-s) SUB="$2"; shift 2 ;;
--sort) SORT="$2"; shift 2 ;;
--time|-t) TIME="$2"; shift 2 ;;
--limit|-l) LIMIT="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$QUERY" ] || die "Usage: search.sh --query \"text\" [--subreddit <name>] [--sort S] [--time T] [--limit N]"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
if [ -n "$SUB" ]; then
PATH_PART="/r/${SUB}/search"
SCOPE="r/${SUB}"
EXTRA_ARGS='--data-urlencode restrict_sr=on'
else
PATH_PART="/search"
SCOPE="all of Reddit"
EXTRA_ARGS=""
fi
KEY=$(cache_key "search|${SUB}|${QUERY}|${SORT}|${TIME}|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
# shellcheck disable=SC2086
reddit_get "$PATH_PART" "$OUT" \
--data-urlencode "q=${QUERY}" \
--data-urlencode "sort=${SORT}" \
--data-urlencode "t=${TIME}" \
--data-urlencode "limit=${LIMIT}" \
$EXTRA_ARGS
fi
echo "Search results for \"${QUERY}\" in ${SCOPE} (sort=${SORT}, time=${TIME}, limit=${LIMIT}):"
print_listing_summary "$OUT" "$LIMIT"
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# submission.sh — fetch a submission (post) by id or URL, with comments.
# Usage: submission.sh (--id <base36> | --url <url>) [--limit-comments 25] [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
ARG=""; LIMIT="25"; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--id) ARG="$2"; shift 2 ;;
--url) ARG="$2"; shift 2 ;;
--limit-comments) LIMIT="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$ARG" ] || die "Usage: submission.sh (--id <base36> | --url <url>) [--limit-comments N]"
ID=$(parse_submission_id "$ARG") || die "Could not parse submission id from: $ARG" \
"Expected base36 id (e.g. abc123), t3_<id>, or a Reddit URL with /comments/<id>/."
KEY=$(cache_key "submission|${ID}|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/comments/${ID}" "$OUT" --data-urlencode "limit=${LIMIT}"
fi
_F="$OUT" _N="$LIMIT" python3 - <<'PY'
import json, os
n = int(os.environ["_N"])
with open(os.environ["_F"]) as f:
d = json.load(f)
# /comments/{id} returns [post_listing, comment_listing]
if not (isinstance(d, list) and len(d) >= 2):
print("(unexpected payload shape)")
raise SystemExit
post = d[0]["data"]["children"][0]["data"]
comments = d[1]["data"]["children"]
print(f"Post: {post.get('title','')}")
print(f" r/{post.get('subreddit','?')} by u/{post.get('author','?')}")
print(f" Score: {post.get('score',0)} ↑ Comments: {post.get('num_comments',0)} 💬 UpvoteRatio: {post.get('upvote_ratio','?')}")
print(f" URL: https://www.reddit.com{post.get('permalink','')}")
body = (post.get('selftext') or '').strip()
if body:
print(f" Body: {body[:400]}")
print()
shown = 0
print(f"Top-level comments (showing up to {n}):")
for c in comments[:n]:
if c.get("kind") != "t1":
continue
cd = c["data"]
body = (cd.get("body") or "").replace("\n", " ").strip()[:200]
score = cd.get("score", 0)
author = cd.get("author", "?")
print(f" [{score:>5} ↑] u/{author}: {body}")
shown += 1
total = sum(1 for c in comments if c.get("kind") == "t1")
if total > shown:
print(f" ... ({total - shown} more in cache file)")
PY
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# subreddit_info.sh — subreddit /about metadata.
# Usage: subreddit_info.sh --subreddit <name> [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
SUB=""; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--subreddit|-s) SUB="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$SUB" ] || die "Usage: subreddit_info.sh --subreddit <name>"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
OUT="$REDDIT_CACHE_DIR/subreddits/${SUB}.json"
mkdir -p "$REDDIT_CACHE_DIR/subreddits"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/r/${SUB}/about" "$OUT"
fi
_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f).get("data", {})
print(f"r/{d.get('display_name','?')} — {d.get('title','')}")
print(f" Subscribers : {d.get('subscribers', 0)}")
print(f" Active users : {d.get('accounts_active') or d.get('active_user_count') or 0}")
print(f" Created (UTC) : {d.get('created_utc','?')}")
print(f" NSFW : {d.get('over18', False)}")
print(f" Type : {d.get('subreddit_type','?')}")
print(f" Lang : {d.get('lang','?')}")
desc = (d.get('public_description') or '').strip()
if desc:
print(f" Description : {desc[:300]}")
PY
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# subreddit_popular.sh — list popular subreddits (Reddit's "trending" feed).
# Equivalent to PRAW's reddit.subreddits.popular(limit=N).
# Usage: subreddit_popular.sh [--limit 25] [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
LIMIT="25"; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--limit|-l) LIMIT="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
KEY=$(cache_key "popular|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/subreddits/popular" "$OUT" --data-urlencode "limit=${LIMIT}"
fi
echo "Popular subreddits (top-${LIMIT}):"
print_listing_summary "$OUT" "$LIMIT"
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# subreddit_stats.sh — extended subreddit metrics: about + rules + moderators.
# Usage: subreddit_stats.sh --subreddit <name> [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
SUB=""; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--subreddit|-s) SUB="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$SUB" ] || die "Usage: subreddit_stats.sh --subreddit <name>"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
DIR="$REDDIT_CACHE_DIR/subreddits"
mkdir -p "$DIR"
ABOUT="$DIR/${SUB}.json"
RULES="$DIR/${SUB}.rules.json"
MODS="$DIR/${SUB}.moderators.json"
fetch_or_cache() {
_path="$1"; _file="$2"
if [ -z "$NO_CACHE" ] && [ -s "$_file" ]; then
return 0
fi
reddit_get "$_path" "$_file"
}
fetch_or_cache "/r/${SUB}/about" "$ABOUT"
fetch_or_cache "/r/${SUB}/about/rules" "$RULES"
fetch_or_cache "/r/${SUB}/about/moderators" "$MODS"
_A="$ABOUT" _R="$RULES" _M="$MODS" python3 - <<'PY'
import json, os
with open(os.environ["_A"]) as f:
a = json.load(f).get("data", {})
print(f"r/{a.get('display_name','?')} — {a.get('title','')}")
print(f" Subscribers : {a.get('subscribers', 0)}")
print(f" Active users : {a.get('accounts_active') or a.get('active_user_count') or 0}")
print(f" Created (UTC) : {a.get('created_utc','?')}")
print(f" NSFW : {a.get('over18', False)}")
print(f" Type : {a.get('subreddit_type','?')}")
with open(os.environ["_R"]) as f:
r = json.load(f)
rules = r.get("rules") or []
print(f" Rules ({len(rules)}):")
for i, rule in enumerate(rules[:15], 1):
name = (rule.get("short_name") or rule.get("violation_reason") or "?").strip()
print(f" {i}. {name[:120]}")
if len(rules) > 15:
print(f" ... ({len(rules) - 15} more in {os.environ['_R']})")
with open(os.environ["_M"]) as f:
m = json.load(f).get("data", {})
mods = m.get("children") or []
print(f" Moderators ({len(mods)}):")
for mod in mods[:20]:
name = mod.get("name", "?")
perms = ",".join(mod.get("mod_permissions", [])) or "—"
print(f" u/{name:<24} perms: {perms}")
if len(mods) > 20:
print(f" ... ({len(mods) - 20} more in {os.environ['_M']})")
PY
echo ""
echo "Cache:"
echo " about : $ABOUT"
echo " rules : $RULES"
echo " moderators: $MODS"
#!/bin/sh
# subreddit_subscribe.sh — subscribe / unsubscribe the authenticated user to a subreddit.
# Usage: subreddit_subscribe.sh --subreddit <name> [--unsubscribe] --confirm
#
# Double safeguard: REDDIT_ENABLE_WRITE=1 + --confirm.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
SUB=""; UNSUB=""; CONFIRM=""
while [ $# -gt 0 ]; do
case "$1" in
--subreddit|-s) SUB="$2"; shift 2 ;;
--unsubscribe) UNSUB=1; shift ;;
--confirm) CONFIRM=1; shift ;;
*) shift ;;
esac
done
[ -n "$SUB" ] || die "Usage: subreddit_subscribe.sh --subreddit <name> [--unsubscribe] --confirm"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
if [ -n "$UNSUB" ]; then
ACTION_VERB="unsubscribe from"
ACTION_VAL="unsub"
else
ACTION_VERB="subscribe to"
ACTION_VAL="sub"
fi
ACTION="${ACTION_VERB} r/${SUB}"
if ! require_write_enabled "$CONFIRM" "$ACTION"; then
echo " subreddit : r/${SUB}"
echo " action : ${ACTION_VAL}"
exit 0
fi
OUT="$REDDIT_TMPDIR/reddit_subscribe.$$.json"
trap 'rm -f "$OUT"' EXIT
reddit_post "/api/subscribe" "$OUT" \
--data-urlencode "action=${ACTION_VAL}" \
--data-urlencode "sr_name=${SUB}" \
--data-urlencode "api_type=json"
# /api/subscribe returns {} on success
echo "OK: ${ACTION}"
#!/bin/sh
# subreddit_top.sh — top posts from a subreddit.
# Usage: subreddit_top.sh --subreddit <name> [--time hour|day|week|month|year|all]
# [--limit 25] [--include-comments [--comments-per-post 25]]
# [--no-cache]
#
# --include-comments fetches /comments/{id} for each top post and stores the
# combined artifact under cache/top_with_comments/<key>/. Use sparingly —
# each post = 1 extra API call.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
SUB=""; TIME="day"; LIMIT="25"; NO_CACHE=""
INCLUDE_COMMENTS=""; COMMENTS_PER_POST="25"
while [ $# -gt 0 ]; do
case "$1" in
--subreddit|-s) SUB="$2"; shift 2 ;;
--time|-t) TIME="$2"; shift 2 ;;
--limit|-l) LIMIT="$2"; shift 2 ;;
--include-comments) INCLUDE_COMMENTS=1; shift ;;
--comments-per-post) COMMENTS_PER_POST="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$SUB" ] || die "Usage: subreddit_top.sh --subreddit <name> [--time T] [--limit N] [--include-comments]"
SUB=$(printf '%s' "$SUB" | sed 's|^/*r/||')
KEY=$(cache_key "top|${SUB}|${TIME}|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/r/${SUB}/top" "$OUT" \
--data-urlencode "t=${TIME}" \
--data-urlencode "limit=${LIMIT}"
fi
echo "Top posts in r/${SUB} (time=${TIME}, limit=${LIMIT}):"
print_listing_summary "$OUT" "$LIMIT"
echo ""
echo "Full payload: $OUT"
# --- Include-comments: per-post fetch ----------------------------------------
if [ -n "$INCLUDE_COMMENTS" ]; then
# Detail cache key MUST include COMMENTS_PER_POST — otherwise raising the
# limit later would silently re-use shorter cached files.
DETAIL_KEY=$(cache_key "top_with_comments|${SUB}|${TIME}|${LIMIT}|cpp=${COMMENTS_PER_POST}")
DETAIL_DIR="$REDDIT_CACHE_DIR/top_with_comments/${DETAIL_KEY}"
mkdir -p "$DETAIL_DIR"
# Extract post ids from listing
IDS=$(_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f)
ids = []
for c in d.get("data", {}).get("children", []):
if c.get("kind") == "t3":
cid = c.get("data", {}).get("id")
if cid:
ids.append(cid)
print(" ".join(ids))
PY
)
if [ -z "$IDS" ]; then
echo "(no post ids found in listing)"
exit 0
fi
echo ""
echo "Fetching comments for each post (limit=${COMMENTS_PER_POST}/post)..."
_count=0
for _id in $IDS; do
_count=$((_count + 1))
_file="$DETAIL_DIR/${_id}.json"
if [ -z "$NO_CACHE" ] && [ -s "$_file" ]; then
continue
fi
reddit_get "/comments/${_id}" "$_file" \
--data-urlencode "limit=${COMMENTS_PER_POST}" \
>/dev/null
done
echo "Comments cached for ${_count} posts: ${DETAIL_DIR}"
fi
#!/bin/sh
# Run all reddit-skill no-network tests.
# Usage: sh scripts/tests/run.sh
set -e
TESTS_DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
FAILED_NAMES=""
for t in "$TESTS_DIR"/test_*.sh; do
name=$(basename "$t" .sh)
printf " %-40s " "$name"
if sh "$t" >/dev/null 2>&1; then
echo "OK"
PASS=$((PASS + 1))
else
echo "FAIL"
FAIL=$((FAIL + 1))
FAILED_NAMES="${FAILED_NAMES}${name} "
fi
done
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"
if [ "$FAIL" -gt 0 ]; then
echo "Failed: ${FAILED_NAMES}"
echo "Re-run individually with full output: sh scripts/tests/<name>.sh"
exit 1
fi
#!/bin/sh
# Tests for cache_key() determinism and uniqueness.
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TEST_DIR/.." && pwd)"
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR"
REDDIT_SKILL_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
REDDIT_CONFIG_DIR="$REDDIT_SKILL_DIR/config"
REDDIT_CACHE_DIR="${TMPDIR:-/tmp}/reddit-test-cache.$$"
mkdir -p "$REDDIT_CACHE_DIR"
trap 'rm -rf "$REDDIT_CACHE_DIR"' EXIT
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
assert_eq() {
_got="$1"; _want="$2"; _msg="$3"
[ "$_got" = "$_want" ] || { echo "FAIL: $_msg (got='$_got' want='$_want')" >&2; exit 1; }
}
# 1. Determinism: same input → same output
k1=$(cache_key "search|python|new|all|25")
k2=$(cache_key "search|python|new|all|25")
assert_eq "$k1" "$k2" "same input → same key"
# 2. Different inputs → different keys
k3=$(cache_key "search|python|new|all|26")
if [ "$k1" = "$k3" ]; then
echo "FAIL: different inputs produced same key" >&2
exit 1
fi
# 3. Format: 8 hex chars
case "$k1" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) : ;;
*)
echo "FAIL: cache_key not 8-hex format: '$k1'" >&2
exit 1
;;
esac
# 4. Empty input still produces a key
k4=$(cache_key "")
[ -n "$k4" ] || { echo "FAIL: empty input → empty key" >&2; exit 1; }
echo "test_cache_key: all assertions passed"
#!/bin/sh
# Tests load_config() auth-mode selection logic without network.
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TEST_DIR/.." && pwd)"
TMP_BASE="${TMPDIR:-/tmp}/reddit-test-config.$$"
mkdir -p "$TMP_BASE/config" "$TMP_BASE/cache"
trap 'rm -rf "$TMP_BASE"' EXIT
# Note: .env values must be valid sh assignments. Quote anything with spaces/parens.
# --- Case 1: app-only mode (no username/password) ---
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_ID=test_id
REDDIT_CLIENT_SECRET=test_secret
REDDIT_USER_AGENT="test:reddit-skill:0.0.1 (by /u/test)"
EOF
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh -c '
unset REDDIT_USERNAME REDDIT_PASSWORD
. "'"$SCRIPTS_DIR"'/common.sh"
load_config
[ "$REDDIT_AUTH_MODE" = "app-only" ] || { echo "FAIL: case1 mode=$REDDIT_AUTH_MODE" >&2; exit 1; }
[ "$REDDIT_CLIENT_ID" = "test_id" ] || { echo "FAIL: case1 client_id=$REDDIT_CLIENT_ID" >&2; exit 1; }
'
# --- Case 2: user mode (username + password set) ---
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_ID=test_id
REDDIT_CLIENT_SECRET=test_secret
REDDIT_USER_AGENT="test:reddit-skill:0.0.1 (by /u/test)"
REDDIT_USERNAME=alice
REDDIT_PASSWORD=hunter2
EOF
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh -c '
. "'"$SCRIPTS_DIR"'/common.sh"
load_config
[ "$REDDIT_AUTH_MODE" = "user" ] || { echo "FAIL: case2 mode=$REDDIT_AUTH_MODE" >&2; exit 1; }
'
# --- Case 3: missing CLIENT_ID → die (subshell exits non-zero) ---
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_SECRET=test_secret
REDDIT_USER_AGENT="test:reddit-skill:0.0.1 (by /u/test)"
EOF
if REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh -c '
unset REDDIT_CLIENT_ID REDDIT_USERNAME REDDIT_PASSWORD
. "'"$SCRIPTS_DIR"'/common.sh"
load_config
' >/dev/null 2>&1; then
echo "FAIL: case3 should fail without CLIENT_ID" >&2
exit 1
fi
# --- Case 4: missing USER_AGENT → die ---
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_ID=test_id
REDDIT_CLIENT_SECRET=test_secret
EOF
if REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh -c '
unset REDDIT_USER_AGENT REDDIT_USERNAME REDDIT_PASSWORD
. "'"$SCRIPTS_DIR"'/common.sh"
load_config
' >/dev/null 2>&1; then
echo "FAIL: case4 should fail without USER_AGENT" >&2
exit 1
fi
echo "test_load_config: all assertions passed"
#!/bin/sh
# Tests token cache: fresh tokens are returned, expired/wrong-mode tokens are not.
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TEST_DIR/.." && pwd)"
TMP_BASE="${TMPDIR:-/tmp}/reddit-test-token.$$"
mkdir -p "$TMP_BASE/cache"
trap 'rm -rf "$TMP_BASE"' EXIT
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR"
REDDIT_SKILL_DIR="$TMP_BASE"
REDDIT_CONFIG_DIR="$TMP_BASE/config"
REDDIT_CACHE_DIR="$TMP_BASE/cache"
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
NOW=$(date +%s)
# 1. Fresh app-only token → returned
cat > "$TMP_BASE/cache/token.json" <<EOF
{"access_token":"fresh_token_xyz","expires_at":$((NOW + 3600)),"scope":"*","mode":"app-only"}
EOF
REDDIT_AUTH_MODE="app-only"
got=$(_get_cached_token)
[ "$got" = "fresh_token_xyz" ] || {
echo "FAIL: fresh token should be returned (got='$got')" >&2
exit 1
}
# 2. Expired token → empty
cat > "$TMP_BASE/cache/token.json" <<EOF
{"access_token":"old_token","expires_at":$((NOW - 100)),"scope":"*","mode":"app-only"}
EOF
got=$(_get_cached_token)
[ -z "$got" ] || {
echo "FAIL: expired token should be filtered out (got='$got')" >&2
exit 1
}
# 3. Token expiring within safety margin (<30s) → filtered
cat > "$TMP_BASE/cache/token.json" <<EOF
{"access_token":"borderline","expires_at":$((NOW + 10)),"scope":"*","mode":"app-only"}
EOF
got=$(_get_cached_token)
[ -z "$got" ] || {
echo "FAIL: token within 30s margin should be filtered (got='$got')" >&2
exit 1
}
# 4. Wrong mode (cache=user, asking app-only) → empty
cat > "$TMP_BASE/cache/token.json" <<EOF
{"access_token":"user_tok","expires_at":$((NOW + 3600)),"scope":"identity","mode":"user"}
EOF
REDDIT_AUTH_MODE="app-only"
got=$(_get_cached_token)
[ -z "$got" ] || {
echo "FAIL: cross-mode cache hit (got='$got')" >&2
exit 1
}
# 5. Same mode → returned
REDDIT_AUTH_MODE="user"
got=$(_get_cached_token)
[ "$got" = "user_tok" ] || {
echo "FAIL: same-mode token should be returned (got='$got')" >&2
exit 1
}
# 6. No cache file → empty
rm -f "$TMP_BASE/cache/token.json"
got=$(_get_cached_token)
[ -z "$got" ] || {
echo "FAIL: no cache file should yield empty (got='$got')" >&2
exit 1
}
echo "test_token_expiry: all assertions passed"
#!/bin/sh
# Tests for parse_submission_id().
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TEST_DIR/.." && pwd)"
# Make common.sh sourceable in tests without forcing the full load_config flow.
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR"
REDDIT_SKILL_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
REDDIT_CONFIG_DIR="$REDDIT_SKILL_DIR/config"
REDDIT_CACHE_DIR="${TMPDIR:-/tmp}/reddit-test-cache.$$"
mkdir -p "$REDDIT_CACHE_DIR"
trap 'rm -rf "$REDDIT_CACHE_DIR"' EXIT
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
assert_eq() {
_got="$1"; _want="$2"; _msg="$3"
if [ "$_got" != "$_want" ]; then
echo "FAIL: $_msg" >&2
echo " got : '$_got'" >&2
echo " want: '$_want'" >&2
exit 1
fi
}
# 1. Bare base36 id
got=$(parse_submission_id "abc123")
assert_eq "$got" "abc123" "bare id"
# 2. Mixed case bare id → lowercase
got=$(parse_submission_id "AbC123")
assert_eq "$got" "abc123" "uppercase id normalized to lowercase"
# 3. With t3_ prefix
got=$(parse_submission_id "t3_xyz789")
assert_eq "$got" "xyz789" "t3_ prefix stripped"
# 4. Long URL with slug
got=$(parse_submission_id "https://www.reddit.com/r/Python/comments/abc123/some_title/")
assert_eq "$got" "abc123" "long URL with slug"
# 5. URL without slug
got=$(parse_submission_id "https://www.reddit.com/r/Python/comments/abc123/")
assert_eq "$got" "abc123" "URL without slug"
# 6. URL without trailing slash
got=$(parse_submission_id "https://www.reddit.com/r/Python/comments/abc123")
assert_eq "$got" "abc123" "URL without trailing slash"
# 7. Short redd.it URL
got=$(parse_submission_id "https://redd.it/abc123")
assert_eq "$got" "abc123" "redd.it short URL"
# 8. Invalid input → non-zero exit
if parse_submission_id "" >/dev/null 2>&1; then
echo "FAIL: empty input should fail" >&2
exit 1
fi
if parse_submission_id "not_a_valid_id_!!!" >/dev/null 2>&1; then
echo "FAIL: invalid input should fail" >&2
exit 1
fi
echo "test_url_parse: all assertions passed"
#!/bin/sh
# Tests require_write_enabled() — the double safeguard for write commands.
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$TEST_DIR/.." && pwd)"
TMP_BASE="${TMPDIR:-/tmp}/reddit-test-guard.$$"
mkdir -p "$TMP_BASE/cache" "$TMP_BASE/config"
trap 'rm -rf "$TMP_BASE"' EXIT
# Helper: run code in subshell, return its exit status without tripping set -e.
run_subshell() {
set +e
(
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR"
REDDIT_SKILL_DIR="$TMP_BASE"
REDDIT_CONFIG_DIR="$TMP_BASE/config"
REDDIT_CACHE_DIR="$TMP_BASE/cache"
# shellcheck disable=SC1091
. "$SCRIPTS_DIR/common.sh"
eval "$1"
) >/dev/null 2>&1
_rc=$?
set -e
return $_rc
}
# --- Case 1: REDDIT_ENABLE_WRITE not set → die (non-zero) ---
if run_subshell '
REDDIT_AUTH_MODE=user
unset REDDIT_ENABLE_WRITE
require_write_enabled "1" "test action"
'; then
echo "FAIL: case1 should fail without REDDIT_ENABLE_WRITE=1" >&2
exit 1
fi
# --- Case 2: app-only mode → die ---
if run_subshell '
REDDIT_AUTH_MODE=app-only
REDDIT_ENABLE_WRITE=1
require_write_enabled "1" "test action"
'; then
echo "FAIL: case2 should fail in app-only mode" >&2
exit 1
fi
# --- Case 3: no --confirm → return non-zero (dry-run path) ---
if run_subshell '
REDDIT_AUTH_MODE=user
REDDIT_ENABLE_WRITE=1
require_write_enabled "" "test action"
'; then
echo "FAIL: case3 dry-run should signal non-zero" >&2
exit 1
fi
# --- Case 4: all conditions met → return 0 ---
if ! run_subshell '
REDDIT_AUTH_MODE=user
REDDIT_ENABLE_WRITE=1
require_write_enabled "1" "test action"
'; then
echo "FAIL: case4 with all conditions should return 0" >&2
exit 1
fi
# --- Case 5a: post_create.sh without REDDIT_ENABLE_WRITE → die, no curl ---
SENTINEL="$TMP_BASE/curl_was_called"
cat > "$TMP_BASE/curl" <<EOF
#!/bin/sh
echo "called" > "$SENTINEL"
exit 0
EOF
chmod +x "$TMP_BASE/curl"
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_ID=x
REDDIT_CLIENT_SECRET=y
REDDIT_USER_AGENT="test"
REDDIT_USERNAME=alice
REDDIT_PASSWORD=hunter2
EOF
# REDDIT_ENABLE_WRITE intentionally NOT set → script dies, no curl
set +e
PATH="$TMP_BASE:$PATH" \
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh "$SCRIPTS_DIR/post_create.sh" \
--subreddit test --title "hello" --content "world" >/dev/null 2>&1
set -e
if [ -f "$SENTINEL" ]; then
echo "FAIL: case5a (no ENABLE_WRITE) — post_create.sh called curl!" >&2
exit 1
fi
# --- Case 5b: post_create.sh with REDDIT_ENABLE_WRITE=1 but no --confirm
# → dry-run, no curl ---
rm -f "$SENTINEL"
cat > "$TMP_BASE/config/.env" <<'EOF'
REDDIT_CLIENT_ID=x
REDDIT_CLIENT_SECRET=y
REDDIT_USER_AGENT="test"
REDDIT_USERNAME=alice
REDDIT_PASSWORD=hunter2
REDDIT_ENABLE_WRITE=1
EOF
set +e
PATH="$TMP_BASE:$PATH" \
REDDIT_SCRIPT_DIR="$SCRIPTS_DIR" \
REDDIT_SKILL_DIR="$TMP_BASE" \
REDDIT_CONFIG_DIR="$TMP_BASE/config" \
REDDIT_CACHE_DIR="$TMP_BASE/cache" \
sh "$SCRIPTS_DIR/post_create.sh" \
--subreddit test --title "hello" --content "world" >/dev/null 2>&1
_rc=$?
set -e
if [ -f "$SENTINEL" ]; then
echo "FAIL: case5b (ENABLE_WRITE=1, no --confirm) — post_create.sh called curl!" >&2
exit 1
fi
# Dry-run path is the success case for the script (exit 0 after printing).
if [ "$_rc" != "0" ]; then
echo "FAIL: case5b dry-run should exit 0 (got $_rc)" >&2
exit 1
fi
echo "test_write_guard: all assertions passed"
#!/bin/sh
# user_comments.sh — comments by a user.
# Usage: user_comments.sh --username <name> [--sort new|hot|top|controversial]
# [--time hour|day|week|month|year|all] [--limit 25] [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
USERNAME=""; SORT="new"; TIME="all"; LIMIT="25"; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--username|-u) USERNAME="$2"; shift 2 ;;
--sort) SORT="$2"; shift 2 ;;
--time|-t) TIME="$2"; shift 2 ;;
--limit|-l) LIMIT="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$USERNAME" ] || die "Usage: user_comments.sh --username <name> [--sort N] [--time T] [--limit N]"
USERNAME=$(printf '%s' "$USERNAME" | sed 's|^/*u/||')
KEY=$(cache_key "user-comments|${USERNAME}|${SORT}|${TIME}|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/user/${USERNAME}/comments" "$OUT" \
--data-urlencode "sort=${SORT}" \
--data-urlencode "t=${TIME}" \
--data-urlencode "limit=${LIMIT}"
fi
echo "Comments by u/${USERNAME} (sort=${SORT}, time=${TIME}, limit=${LIMIT}):"
print_listing_summary "$OUT" "$LIMIT"
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# user_info.sh — Reddit user profile metadata.
# Usage: user_info.sh --username <name> [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
USERNAME=""
NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--username|-u) USERNAME="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$USERNAME" ] || die "Usage: user_info.sh --username <name>"
# Strip leading u/ or /u/
USERNAME=$(printf '%s' "$USERNAME" | sed 's|^/*u/||')
OUT="$REDDIT_CACHE_DIR/users/${USERNAME}.json"
mkdir -p "$REDDIT_CACHE_DIR/users"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/user/${USERNAME}/about" "$OUT"
fi
_F="$OUT" python3 - <<'PY'
import json, os
with open(os.environ["_F"]) as f:
d = json.load(f).get("data", {})
print(f"u/{d.get('name','?')} (id={d.get('id','?')})")
print(f" Created (UTC) : {d.get('created_utc','?')}")
print(f" Comment karma : {d.get('comment_karma',0)}")
print(f" Link karma : {d.get('link_karma',0)}")
print(f" Total karma : {d.get('total_karma', d.get('comment_karma',0)+d.get('link_karma',0))}")
print(f" Is gold : {d.get('is_gold', False)}")
print(f" Is employee : {d.get('is_employee', False)}")
print(f" Verified : {d.get('has_verified_email', False)}")
sub = d.get("subreddit") or {}
if isinstance(sub, dict):
print(f" Profile sub : r/{sub.get('display_name','?')} ({sub.get('subscribers',0)} subs)")
pd = sub.get("public_description") or ""
if pd:
print(f" Bio : {pd.strip()[:200]}")
PY
echo ""
echo "Full payload: $OUT"
#!/bin/sh
# user_posts.sh — submissions by a user.
# Usage: user_posts.sh --username <name> [--sort new|hot|top|controversial]
# [--time hour|day|week|month|year|all] [--limit 25] [--no-cache]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
USERNAME=""; SORT="new"; TIME="all"; LIMIT="25"; NO_CACHE=""
while [ $# -gt 0 ]; do
case "$1" in
--username|-u) USERNAME="$2"; shift 2 ;;
--sort) SORT="$2"; shift 2 ;;
--time|-t) TIME="$2"; shift 2 ;;
--limit|-l) LIMIT="$2"; shift 2 ;;
--no-cache) NO_CACHE=1; shift ;;
*) shift ;;
esac
done
[ -n "$USERNAME" ] || die "Usage: user_posts.sh --username <name> [--sort N] [--time T] [--limit N]"
USERNAME=$(printf '%s' "$USERNAME" | sed 's|^/*u/||')
KEY=$(cache_key "user-posts|${USERNAME}|${SORT}|${TIME}|${LIMIT}")
OUT="$REDDIT_CACHE_DIR/listings/${KEY}.json"
mkdir -p "$REDDIT_CACHE_DIR/listings"
if [ -z "$NO_CACHE" ] && [ -s "$OUT" ]; then
echo "(cached: $OUT)"
else
reddit_get "/user/${USERNAME}/submitted" "$OUT" \
--data-urlencode "sort=${SORT}" \
--data-urlencode "t=${TIME}" \
--data-urlencode "limit=${LIMIT}"
fi
echo "Posts by u/${USERNAME} (sort=${SORT}, time=${TIME}, limit=${LIMIT}):"
print_listing_summary "$OUT" "$LIMIT"
echo ""
echo "Full payload: $OUT"
Related skills
FAQ
How are Reddit writes protected?
By a double safety gate: writes fail without REDDIT_ENABLE_WRITE=1, run as a dry-run with the flag but no --confirm, and only reach Reddit with both the flag and --confirm.
Does reddit-skill use PRAW?
No. It accesses the Reddit API directly over OAuth2 using shell scripts, ported from the Arindam200/reddit-mcp server.