Skill: theglove44/reddit (version 1.0.0)

--- name: reddit description: Browse, search, post, and moderate Reddit. Read-only works without auth; posting/moderation requires OAuth setup. metadata: {"clawdbot":{"emoji":"📣","requires":{"bins":["node"]}}} --- # Reddit Browse, search, post to, and moderate subreddits. Read-only actions work without auth; posting/moderation requires OAuth setup. ## Setup (for posting/moderation) 1. Go to https://www.reddit.com/prefs/apps 2. Click "create another app..." 3. Select "script" type 4. Set redirect URI to `http://localhost:8080` 5. Note your client ID (under app name) and client secret 6. Set environment variables: ```bash export REDDIT_CLIENT_ID="your_client_id" export REDDIT_CLIENT_SECRET="your_client_secret" export REDDIT_USERNAME="your_username" export REDDIT_PASSWORD="your_password" ``` ## Read Posts (no auth required) ```bash # Hot posts from a subreddit node {baseDir}/scripts/reddit.mjs posts wallstreetbets # New posts node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort new # Top posts (day/week/month/year/all) node {baseDir}/scripts/reddit.mjs posts wallstreetbets --sort top --time week # Limit results node {baseDir}/scripts/reddit.mjs posts wallstreetbets --limit 5 ``` ## Search Posts ```bash # Search within a subreddit node {baseDir}/scripts/reddit.mjs search wallstreetbets "YOLO" # Search all of Reddit node {baseDir}/scripts/reddit.mjs search all "stock picks" ``` ## Get Comments on a Post ```bash # By post ID or full URL node {baseDir}/scripts/reddit.mjs comments POST_ID node {baseDir}/scripts/reddit.mjs comments "https://reddit.com/r/subreddit/comments/abc123/..." ``` ## Submit a Post (requires auth) ```bash # Text post node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Weekly Discussion" --text "What's on your mind?" # Link post node {baseDir}/scripts/reddit.mjs submit yoursubreddit --title "Great article" --url "https://example.com/article" ``` ## Reply to a Post/Comment (requires auth) ```bash node {baseDir}/scripts/reddit.mjs reply THING_ID "Your reply text here" ``` ## Moderation (requires auth + mod permissions) ```bash # Remove a post/comment node {baseDir}/scripts/reddit.mjs mod remove THING_ID # Approve a post/comment node {baseDir}/scripts/reddit.mjs mod approve THING_ID # Sticky a post node {baseDir}/scripts/reddit.mjs mod sticky POST_ID # Unsticky node {baseDir}/scripts/reddit.mjs mod unsticky POST_ID # Lock comments node {baseDir}/scripts/reddit.mjs mod lock POST_ID # View modqueue node {baseDir}/scripts/reddit.mjs mod queue yoursubreddit ``` ## Notes - Read actions use Reddit's public JSON API (no auth needed) - Post/mod actions require OAuth - run `login` command once to authorize - Token stored at `~/.reddit-token.json` (auto-refreshes) - Rate limits: ~60 requests/minute for OAuth, ~10/minute for unauthenticated

path: README.md
content: # Reddit Skill for Clawdbot

Browse, search, post to, and moderate any subreddit from your agent.

## Quick Start

**Read-only** (no setup needed):
```bash
node scripts/reddit.mjs posts news --limit 5
node scripts/reddit.mjs search all "breaking news"
```

**Posting & Moderation** (requires OAuth):
1. Create a Reddit app at https://www.reddit.com/prefs/apps
2. Set environment variables (see Setup below)
3. Run `node scripts/reddit.mjs login` once to authorize

---

## Setup for Posting/Moderation

### 1. Create a Reddit App

1. Go to https://www.reddit.com/prefs/apps
2. Scroll down and click **"create another app..."**
3. Fill in:
   - **name**: anything (e.g., "clawdbot")
   - **type**: select **script**
   - **redirect uri**: `http://localhost:8080/callback`
4. Click **Create app**
5. Note your:
   - **Client ID** — the string under your app name
   - **Client Secret** — labeled "secret"

### 2. Set Environment Variables

Add these to your shell profile or Clawdbot's environment:

```bash
export REDDIT_CLIENT_ID="your_client_id"
export REDDIT_CLIENT_SECRET="your_client_secret"
export REDDIT_USERNAME="your_reddit_username"
export REDDIT_PASSWORD="your_reddit_password"
```

### 3. Authorize (One Time)

```bash
node scripts/reddit.mjs login
```

This opens a browser for OAuth. After authorizing, a token is saved to `~/.reddit-token.json` and auto-refreshes.

---

## Personalizing the Skill

The `SKILL.md` file tells your agent how to use this skill. You'll want to customize it for your setup:

### Update the Examples

Replace the generic subreddit names (`wallstreetbets`, `yoursubreddit`) with the ones you actually use:

```markdown
# Before
node {baseDir}/scripts/reddit.mjs posts wallstreetbets

# After
node {baseDir}/scripts/reddit.mjs posts mysubreddit
```

### Add Your Subreddits to the Notes

At the bottom of `SKILL.md`, add a section listing your subreddits:

```markdown
## My Subreddits

- **r/mysubreddit** — I'm a mod here (full access)
- **r/interestingtopic** — I follow this one
- **r/anotherone** — Read-only
```

This helps your agent know what it can do where.

### Customize the User-Agent (Optional)

In `scripts/reddit.mjs`, you can personalize the User-Agent string:

```javascript
// Find this line near the top:
const USER_AGENT = 'script:clawdbot-reddit:v1.0.0';

// Change to something like:
const USER_AGENT = 'script:my-reddit-bot:v1.0.0 (by /u/your_username)';
```

Reddit recommends including your username so they can contact you if needed.

---

## Commands Reference

| Command | Auth Required | Description |
|---------|---------------|-------------|
| `posts <subreddit>` | No | Get hot/new/top posts |
| `search <subreddit\|all> <query>` | No | Search posts |
| `comments <post_id>` | No | Get comments on a post |
| `submit <subreddit> --title "..." --text "..."` | Yes | Create a text post |
| `submit <subreddit> --title "..." --url "..."` | Yes | Create a link post |
| `reply <thing_id> "text"` | Yes | Reply to a post or comment |
| `mod remove <thing_id>` | Yes + Mod | Remove post/comment |
| `mod approve <thing_id>` | Yes + Mod | Approve post/comment |
| `mod sticky <post_id>` | Yes + Mod | Sticky a post |
| `mod queue <subreddit>` | Yes + Mod | View mod queue |
| `login` | — | Start OAuth flow |
| `whoami` | Yes | Check logged-in user |

### Options

- `--sort hot|new|top|controversial` — Sort order for posts
- `--time day|week|month|year|all` — Time filter for top/controversial
- `--limit N` — Number of results (default: 25)

---

## Rate Limits

- **With OAuth**: ~60 requests/minute
- **Without OAuth**: ~10 requests/minute

The skill handles token refresh automatically.

---

## Troubleshooting

**"Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET"**  
→ Environment variables aren't set. Check your shell profile or Clawdbot config.

**"Not logged in. Run: node reddit.mjs login"**  
→ You need to authorize first. Run the login command.

**"Reddit returned HTML instead of JSON"**  
→ Reddit sometimes does this under load. Wait a moment and try again.

**Token file location**: `~/.reddit-token.json`  
→ Delete this file to force re-authorization.

---

## License

MIT — do whatever you want with it.


path: scripts/reddit.mjs
content: #!/usr/bin/env node

/**
 * Reddit CLI - Browse, search, post, and moderate subreddits
 * 
 * Read-only: Uses public JSON API (no auth)
 * Write/Mod: Requires OAuth - run `login` command first
 */

import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { exec } from 'child_process';

const BASE_URL = 'https://www.reddit.com';
const OAUTH_URL = 'https://oauth.reddit.com';
const USER_AGENT = 'script:clawdbot-reddit:v1.0.0';
const TOKEN_FILE = join(homedir(), '.reddit-token.json');
const REDIRECT_URI = 'http://localhost:8080/callback';
const SCOPES = 'read submit edit identity mysubreddits modposts modcontributors modmail modconfig modlog modself flair';

let tokenCache = null;

function getClientCreds() {
  const { REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET } = process.env;
  if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET) {
    throw new Error('Missing REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET');
  }
  return { clientId: REDDIT_CLIENT_ID, clientSecret: REDDIT_CLIENT_SECRET };
}

function loadToken() {
  if (tokenCache) return tokenCache;
  if (!existsSync(TOKEN_FILE)) return null;
  try {
    tokenCache = JSON.parse(readFileSync(TOKEN_FILE, 'utf-8'));
    return tokenCache;
  } catch {
    return null;
  }
}

function saveToken(token) {
  tokenCache = token;
  writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2));
}

async function refreshAccessToken() {
  const [REDACTED_SECRET]);
  if (!token?.refresh_token) {
    throw new Error('Not logged in. Run: node reddit.mjs login');
  }
  
  const { clientId, clientSecret } = getClientCreds();
  const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
  
  const res = await fetch('https://www.reddit.com/api/v1/access_token', {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${auth}`,
      'Content-Type': 'application/x-www-form-urlencoded',
      'User-Agent': USER_AGENT,
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: token.refresh_token,
    }),
  });
  
  if (!res.ok) {
    throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`);
  }
  
  const data = await res.json();
  const newToken = {
    access_token: data.access_token,
    refresh_token: token.refresh_token, // Reddit doesn't always return a new refresh token
    expires_at: Date.now() + (data.expires_in * 1000),
  };
  saveToken(newToken);
  return newToken.access_token;
}

async function getAccessToken() {
  const [REDACTED_SECRET]);
  if (!token) {
    throw new Error('Not logged in. Run: node reddit.mjs login');
  }
  
  // Refresh if expired or expiring soon (5 min buffer)
  if (Date.now() > (token.expires_at - 300000)) {
    return refreshAccessToken();
  }
  
  return token.access_token;
}

async function publicFetch(path) {
  // Insert .json before query string if present
  let url;
  if (path.includes('?')) {
    const [basePath, query] = path.split('?');
    url = `${BASE_URL}${basePath}.json?${query}`;
  } else {
    url = `${BASE_URL}${path}.json`;
  }
  
  const res = await fetch(url, {
    headers: { 
      'User-Agent': USER_AGENT,
      'Accept': 'application/json',
    },
  });
  
  const text = await res.text();
  
  if (!res.ok) {
    throw new Error(`Request failed: ${res.status} ${text.slice(0, 200)}`);
  }
  
  // Check if we got HTML instead of JSON (Reddit sometimes does this)
  if (text.trim().startsWith('<')) {
    throw new Error('Reddit returned HTML instead of JSON. Try again in a moment.');
  }
  
  return JSON.parse(text);
}

async function oauthFetch(path, options = {}) {
  const token = await getAccessToken();
  const url = `${OAUTH_URL}${path}`;
  
  const res = await fetch(url, {
    ...options,
    headers: {
      'Authorization': `Bearer ${token}`,
      'User-Agent': USER_AGENT,
      ...options.headers,
    },
  });
  
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Request failed: ${res.status} ${text.slice(0, 500)}`);
  }
  
  return res.json();
}

async function oauthPost(path, data) {
  return oauthFetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams(data),
  });
}

// OAuth Login Flow

async function login() {
  const { clientId, clientSecret } = getClientCreds();
  
  const state = Math.random().toString(36).slice(2);
  const authUrl = `https://www.reddit.com/api/v1/authorize?client_id=${clientId}&response_type=code&state=${state}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&duration=permanent&scope=${encodeURIComponent(SCOPES)}`;
  
  console.log('\n🔐 Reddit OAuth Login\n');
  console.log('Open this URL in your browser:\n');
  console.log(authUrl);
  console.log('\nWaiting for authorization...\n');
  
  // Start local server to catch the callback
  return new Promise((resolve, reject) => {
    const server = createServer(async (req, res) => {
      if (!req.url.startsWith('/callback')) {
        res.writeHead(404);
        res.end('Not found');
        return;
      }
      
      const url = new URL(req.url, 'http://localhost:8080');
      const code = url.searchParams.get('code');
      const returnedState = url.searchParams.get('state');
      const error = url.searchParams.get('error');
      
      if (error) {
        res.writeHead(400);
        res.end(`Authorization failed: ${error}`);
        server.close();
        reject(new Error(`Authorization failed: ${error}`));
        return;
      }
      
      if (returnedState !== state) {
        res.writeHead(400);
        res.end('State mismatch - possible CSRF attack');
        server.close();
        reject(new Error('State mismatch'));
        return;
      }
      
      // Exchange code for token
      const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
      
      try {
        const tokenRes = await fetch('https://www.reddit.com/api/v1/access_token', {
          method: 'POST',
          headers: {
            'Authorization': `Basic ${auth}`,
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': USER_AGENT,
          },
          body: new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            redirect_uri: REDIRECT_URI,
          }),
        });
        
        if (!tokenRes.ok) {
          throw new Error(`Token exchange failed: ${await tokenRes.text()}`);
        }
        
        const tokenData = await tokenRes.json();
        
        const token = {
          access_token: tokenData.access_token,
          refresh_token: tokenData.refresh_token,
          expires_at: Date.now() + (tokenData.expires_in * 1000),
        };
        
        saveToken(token);
        
        res.writeHead(200, { 'Content-Type': 'text/html' });
        res.end('<html><body><h1>✅ Logged in!</h1><p>You can close this tab.</p></body></html>');
        
        console.log('✅ Successfully logged in! Token saved to ~/.reddit-token.json\n');
        
        server.close();
        resolve();
      } catch (err) {
        res.writeHead(500);
        res.end(`Error: ${err.message}`);
        server.close();
        reject(err);
      }
    });
    
    server.listen(8080, () => {
      // Try to open browser automatically
      const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
      exec(`${cmd} "${authUrl}"`);
    });
    
    // Timeout after 5 minutes
    setTimeout(() => {
      server.close();
      reject(new Error('Login timed out'));
    }, 300000);
  });
}

// Commands

async function getPosts(subreddit, { sort = 'hot', time = 'day', limit = 25 }) {
  const timeSuffix = sort === 'top' || sort === 'controversial' ? `?t=${time}&limit=${limit}` : `?limit=${limit}`;
  const data = await publicFetch(`/r/${subreddit}/${sort}${timeSuffix}`);
  
  const posts = data.data.children.map(p => ({
    id: p.data.id,
    title: p.data.title,
    author: p.data.author,
    score: p.data.score,
    comments: p.data.num_comments,
    url: p.data.url,
    permalink: `https://reddit.com${p.data.permalink}`,
    created: new Date(p.data.created_utc * 1000).toISOString(),
    selftext: p.data.selftext?.slice(0, 500) || null,
    flair: p.data.link_flair_text || null,
  }));
  
  console.log(JSON.stringify(posts, null, 2));
}

async function searchPosts(subreddit, query, { sort = 'relevance', time = 'all', limit = 25 }) {
  const path = subreddit === 'all' 
    ? `/search?q=${encodeURIComponent(query)}&sort=${sort}&t=${time}&limit=${limit}`
    : `/r/${subreddit}/search?q=${encodeURIComponent(query)}&restrict_sr=on&sort=${sort}&t=${time}&limit=${limit}`;
  
  const data = await publicFetch(path);
  
  const posts = data.data.children.map(p => ({
    id: p.data.id,
    subreddit: p.data.subreddit,
    title: p.data.title,
    author: p.data.author,
    score: p.data.score,
    comments: p.data.num_comments,
    permalink: `https://reddit.com${p.data.permalink}`,
    created: new Date(p.data.created_utc * 1000).toISOString(),
  }));
  
  console.log(JSON.stringify(posts, null, 2));
}

async function getComments(postId, { limit = 50 }) {
  // Handle full URLs
  if (postId.startsWith('http')) {
    const match = postId.match(/comments\/([a-z0-9]+)/i);
    if (match) postId = match[1];
  }
  
  // Need to find the subreddit - fetch post info first
  const postData = await publicFetch(`/by_id/t3_${postId}`);
  const subreddit = postData.data.children[0]?.data?.subreddit;
  
  if (!subreddit) throw new Error('Could not find post');
  
  const data = await publicFetch(`/r/${subreddit}/comments/${postId}?limit=${limit}`);
  
  function parseComments(children, depth = 0) {
    const results = [];
    for (const c of children) {
      if (c.kind !== 't1') continue;
      results.push({
        id: c.data.id,
        author: c.data.author,
        body: c.data.body?.slice(0, 1000),
        score: c.data.score,
        depth,
        created: new Date(c.data.created_utc * 1000).toISOString(),
      });
      if (c.data.replies?.data?.children) {
        results.push(...parseComments(c.data.replies.data.children, depth + 1));
      }
    }
    return results;
  }
  
  const comments = parseComments(data[1].data.children);
  console.log(JSON.stringify(comments, null, 2));
}

async function submitPost(subreddit, { title, text, url }) {
  const data = {
    sr: subreddit,
    title,
    kind: url ? 'link' : 'self',
    api_type: 'json',
  };
  
  if (url) data.url = url;
  if (text) data.text = text;
  
  const result = await oauthPost('/api/submit', data);
  
  if (result.json?.errors?.length) {
    throw new Error(`Submit failed: ${JSON.stringify(result.json.errors)}`);
  }
  
  console.log(JSON.stringify({
    success: true,
    url: result.json?.data?.url,
    id: result.json?.data?.id,
  }, null, 2));
}

async function reply(thingId, text) {
  // Ensure proper prefix
  if (!thingId.startsWith('t1_') && !thingId.startsWith('t3_')) {
    // Guess based on length - posts are usually shorter IDs
    thingId = `t1_${thingId}`; // Assume comment by default
  }
  
  const result = await oauthPost('/api/comment', {
    thing_id: thingId,
    text,
    api_type: 'json',
  });
  
  if (result.json?.errors?.length) {
    throw new Error(`Reply failed: ${JSON.stringify(result.json.errors)}`);
  }
  
  console.log(JSON.stringify({
    success: true,
    id: result.json?.data?.things?.[0]?.data?.id,
  }, null, 2));
}

async function modAction(action, thingId, subreddit) {
  // Ensure proper prefix for IDs
  if (thingId && !thingId.startsWith('t1_') && !thingId.startsWith('t3_')) {
    thingId = `t3_${thingId}`; // Assume post
  }
  
  switch (action) {
    case 'remove':
      await oauthPost('/api/remove', { id: thingId, spam: false });
      console.log(JSON.stringify({ success: true, action: 'removed', id: thingId }));
      break;
      
    case 'approve':
      await oauthPost('/api/approve', { id: thingId });
      console.log(JSON.stringify({ success: true, action: 'approved', id: thingId }));
      break;
      
    case 'sticky':
      await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: true });
      console.log(JSON.stringify({ success: true, action: 'stickied', id: thingId }));
      break;
      
    case 'unsticky':
      await oauthPost('/api/set_subreddit_sticky', { id: thingId, state: false });
      console.log(JSON.stringify({ success: true, action: 'unstickied', id: thingId }));
      break;
      
    case 'lock':
      await oauthPost('/api/lock', { id: thingId });
      console.log(JSON.stringify({ success: true, action: 'locked', id: thingId }));
      break;
      
    case 'unlock':
      await oauthPost('/api/unlock', { id: thingId });
      console.log(JSON.stringify({ success: true, action: 'unlocked', id: thingId }));
      break;
      
    case 'queue':
      if (!subreddit) throw new Error('Subreddit required for modqueue');
      const data = await oauthFetch(`/r/${subreddit}/about/modqueue?limit=25`);
      const items = data.data.children.map(p => ({
        id: p.data.id,
        type: p.kind === 't1' ? 'comment' : 'post',
        author: p.data.author,
        title: p.data.title || p.data.body?.slice(0, 100),
        reports: p.data.num_reports,
        created: new Date(p.data.created_utc * 1000).toISOString(),
      }));
      console.log(JSON.stringify(items, null, 2));
      break;
      
    default:
      throw new Error(`Unknown mod action: ${action}`);
  }
}

async function whoami() {
  const data = await oauthFetch('/api/v1/me');
  console.log(JSON.stringify({
    username: data.name,
    id: data.id,
    karma: data.total_karma,
    created: new Date(data.created_utc * 1000).toISOString(),
  }, null, 2));
}

// CLI Parser

function parseArgs(args) {
  const result = { _: [] };
  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    if (arg.startsWith('--')) {
      const key = arg.slice(2);
      const next = args[i + 1];
      if (next && !next.startsWith('--')) {
        result[key] = next;
        i++;
      } else {
        result[key] = true;
      }
    } else {
      result._.push(arg);
    }
  }
  return result;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const [command, ...rest] = args._;
  
  try {
    switch (command) {
      case 'login': {
        await login();
        break;
      }
      
      case 'whoami': {
        await whoami();
        break;
      }
      
      case 'posts': {
        const [subreddit] = rest;
        if (!subreddit) throw new Error('Usage: posts <subreddit> [--sort hot|new|top] [--time day|week|month|year|all] [--limit N]');
        await getPosts(subreddit, args);
        break;
      }
      
      case 'search': {
        const [subreddit, ...queryParts] = rest;
        const query = queryParts.join(' ');
        if (!subreddit || !query) throw new Error('Usage: search <subreddit|all> <query> [--sort relevance|top|new] [--time all|day|week|month|year] [--limit N]');
        await searchPosts(subreddit, query, args);
        break;
      }
      
      case 'comments': {
        const [postId] = rest;
        if (!postId) throw new Error('Usage: comments <post_id|url> [--limit N]');
        await getComments(postId, args);
        break;
      }
      
      case 'submit': {
        const [subreddit] = rest;
        if (!subreddit || !args.title) throw new Error('Usage: submit <subreddit> --title "Title" [--text "Body"] [--url "URL"]');
        await submitPost(subreddit, args);
        break;
      }
      
      case 'reply': {
        const [thingId, ...textParts] = rest;
        const text = textParts.join(' ');
        if (!thingId || !text) throw new Error('Usage: reply <thing_id> <text>');
        await reply(thingId, text);
        break;
      }
      
      case 'mod': {
        const [action, targetOrSubreddit] = rest;
        if (!action) throw new Error('Usage: mod <remove|approve|sticky|unsticky|lock|unlock|queue> <thing_id|subreddit>');
        await modAction(action, targetOrSubreddit, targetOrSubreddit);
        break;
      }
      
      default:
        console.error(`Commands: login, whoami, posts, search, comments, submit, reply, mod`);
        process.exit(1);
    }
  } catch (err) {
    console.error('Error:', err.message);
    process.exit(1);
  }
}

main();


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)