cli-web-reddit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web/reddit/README.md +68 -0
- cli_web/reddit/__init__.py +3 -0
- cli_web/reddit/__main__.py +6 -0
- cli_web/reddit/commands/__init__.py +0 -0
- cli_web/reddit/commands/actions.py +268 -0
- cli_web/reddit/commands/auth_cmd.py +73 -0
- cli_web/reddit/commands/feed.py +115 -0
- cli_web/reddit/commands/me.py +139 -0
- cli_web/reddit/commands/post.py +93 -0
- cli_web/reddit/commands/search.py +66 -0
- cli_web/reddit/commands/subreddit.py +184 -0
- cli_web/reddit/commands/user.py +90 -0
- cli_web/reddit/core/__init__.py +0 -0
- cli_web/reddit/core/auth.py +204 -0
- cli_web/reddit/core/client.py +475 -0
- cli_web/reddit/core/exceptions.py +63 -0
- cli_web/reddit/core/models.py +253 -0
- cli_web/reddit/reddit_cli.py +174 -0
- cli_web/reddit/skills/SKILL.md +143 -0
- cli_web/reddit/tests/TEST.md +109 -0
- cli_web/reddit/tests/__init__.py +0 -0
- cli_web/reddit/tests/conftest.py +9 -0
- cli_web/reddit/tests/test_core.py +568 -0
- cli_web/reddit/tests/test_e2e.py +312 -0
- cli_web/reddit/utils/__init__.py +0 -0
- cli_web/reddit/utils/doctor.py +188 -0
- cli_web/reddit/utils/helpers.py +91 -0
- cli_web/reddit/utils/mcp_server.py +290 -0
- cli_web/reddit/utils/output.py +133 -0
- cli_web/reddit/utils/repl_skin.py +486 -0
- cli_web_reddit-0.1.0.dist-info/METADATA +15 -0
- cli_web_reddit-0.1.0.dist-info/RECORD +35 -0
- cli_web_reddit-0.1.0.dist-info/WHEEL +5 -0
- cli_web_reddit-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_reddit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Response models for Reddit API data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ts(utc: float | None) -> str:
|
|
9
|
+
"""Convert Unix timestamp to human-readable string."""
|
|
10
|
+
if utc is None:
|
|
11
|
+
return ""
|
|
12
|
+
return datetime.fromtimestamp(utc, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _compact_number(n: int) -> str:
|
|
16
|
+
"""Format large numbers compactly: 1234 -> 1.2k, 1234567 -> 1.2M."""
|
|
17
|
+
if n >= 1_000_000:
|
|
18
|
+
return f"{n / 1_000_000:.1f}M"
|
|
19
|
+
if n >= 1_000:
|
|
20
|
+
return f"{n / 1_000:.1f}k"
|
|
21
|
+
return str(n)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_post_summary(child: dict) -> dict:
|
|
25
|
+
"""Extract key fields from a post (t3) for display."""
|
|
26
|
+
d = child.get("data", child)
|
|
27
|
+
return {
|
|
28
|
+
"id": d.get("id", ""),
|
|
29
|
+
"title": d.get("title", ""),
|
|
30
|
+
"author": d.get("author", "[deleted]"),
|
|
31
|
+
"subreddit": d.get("subreddit", ""),
|
|
32
|
+
"score": d.get("score", 0),
|
|
33
|
+
"num_comments": d.get("num_comments", 0),
|
|
34
|
+
"upvote_ratio": d.get("upvote_ratio", 0),
|
|
35
|
+
"created": _ts(d.get("created_utc")),
|
|
36
|
+
"url": d.get("url", ""),
|
|
37
|
+
"permalink": f"https://www.reddit.com{d.get('permalink', '')}",
|
|
38
|
+
"is_self": d.get("is_self", False),
|
|
39
|
+
"over_18": d.get("over_18", False),
|
|
40
|
+
"stickied": d.get("stickied", False),
|
|
41
|
+
"flair": d.get("link_flair_text") or "",
|
|
42
|
+
"selftext": d.get("selftext", ""),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_post_detail(
|
|
47
|
+
post_data: dict,
|
|
48
|
+
comments_data: dict | None = None,
|
|
49
|
+
more_children_fn=None,
|
|
50
|
+
link_id: str = "",
|
|
51
|
+
thread_fn=None,
|
|
52
|
+
post_id: str = "",
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Full post detail with comments for --json output.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
more_children_fn: Optional callable(link_id, child_ids) -> list[dict]
|
|
58
|
+
to fetch collapsed 'more' comment objects.
|
|
59
|
+
link_id: Post fullname (t3_xxx) for fetching 'more' children.
|
|
60
|
+
thread_fn: Optional callable(post_id, comment_id) -> list to fetch
|
|
61
|
+
'continue this thread' deep comment chains.
|
|
62
|
+
post_id: Post ID (without t3_ prefix) for thread_fn calls.
|
|
63
|
+
"""
|
|
64
|
+
post = format_post_summary(post_data)
|
|
65
|
+
post["selftext"] = (post_data.get("data", post_data)).get("selftext", "")
|
|
66
|
+
|
|
67
|
+
comments: list[dict] = []
|
|
68
|
+
more_ids: list[str] = []
|
|
69
|
+
continue_thread_parents: list[str] = []
|
|
70
|
+
if comments_data:
|
|
71
|
+
_collect_comments(
|
|
72
|
+
comments_data.get("data", {}).get("children", []),
|
|
73
|
+
comments,
|
|
74
|
+
more_ids,
|
|
75
|
+
continue_thread_parents,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Fetch collapsed 'more' comments (have IDs)
|
|
79
|
+
if more_ids and more_children_fn and link_id:
|
|
80
|
+
try:
|
|
81
|
+
extra = more_children_fn(link_id, more_ids)
|
|
82
|
+
for thing in extra:
|
|
83
|
+
if thing.get("kind") == "t1":
|
|
84
|
+
comments.append(format_comment(thing))
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
# Fetch 'continue this thread' deep chains (empty IDs)
|
|
89
|
+
if continue_thread_parents and thread_fn and post_id:
|
|
90
|
+
for parent_id in continue_thread_parents[:5]: # Limit to 5 expansions
|
|
91
|
+
try:
|
|
92
|
+
# parent_id is like "t1_od80pbe" — strip prefix for comment_id
|
|
93
|
+
comment_id = parent_id.replace("t1_", "")
|
|
94
|
+
thread_data = thread_fn(post_id, comment_id)
|
|
95
|
+
if len(thread_data) > 1:
|
|
96
|
+
thread_comments: list[dict] = []
|
|
97
|
+
_collect_comments(
|
|
98
|
+
thread_data[1].get("data", {}).get("children", []),
|
|
99
|
+
thread_comments,
|
|
100
|
+
)
|
|
101
|
+
# Only add comments we don't already have
|
|
102
|
+
existing_ids = {c["id"] for c in comments}
|
|
103
|
+
for c in thread_comments:
|
|
104
|
+
if c["id"] not in existing_ids:
|
|
105
|
+
comments.append(c)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
post["comments"] = comments
|
|
110
|
+
return post
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _collect_comments(
|
|
114
|
+
children: list[dict],
|
|
115
|
+
comments: list[dict],
|
|
116
|
+
more_ids: list[str] | None = None,
|
|
117
|
+
continue_thread_parents: list[str] | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Flatten Reddit comment trees while preserving depth.
|
|
120
|
+
|
|
121
|
+
Collects 'more' object child IDs into more_ids for later fetching.
|
|
122
|
+
Collects parent IDs of 'continue this thread' links into continue_thread_parents.
|
|
123
|
+
"""
|
|
124
|
+
for child in children:
|
|
125
|
+
kind = child.get("kind")
|
|
126
|
+
if kind == "t1":
|
|
127
|
+
comments.append(format_comment(child))
|
|
128
|
+
replies = child.get("data", {}).get("replies")
|
|
129
|
+
if isinstance(replies, dict):
|
|
130
|
+
_collect_comments(
|
|
131
|
+
replies.get("data", {}).get("children", []),
|
|
132
|
+
comments,
|
|
133
|
+
more_ids,
|
|
134
|
+
continue_thread_parents,
|
|
135
|
+
)
|
|
136
|
+
elif kind == "more":
|
|
137
|
+
more_data = child.get("data", {})
|
|
138
|
+
child_ids = more_data.get("children", [])
|
|
139
|
+
if child_ids and more_ids is not None:
|
|
140
|
+
# Normal collapsed comments — fetch via morechildren API
|
|
141
|
+
more_ids.extend(child_ids)
|
|
142
|
+
elif not child_ids and continue_thread_parents is not None:
|
|
143
|
+
# "Continue this thread" — empty IDs, need permalink fetch
|
|
144
|
+
parent = more_data.get("parent_id", "")
|
|
145
|
+
if parent:
|
|
146
|
+
continue_thread_parents.append(parent)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def format_comment(child: dict) -> dict:
|
|
150
|
+
"""Extract key fields from a comment (t1)."""
|
|
151
|
+
d = child.get("data", child)
|
|
152
|
+
return {
|
|
153
|
+
"id": d.get("id", ""),
|
|
154
|
+
"parent_id": d.get("parent_id", ""),
|
|
155
|
+
"author": d.get("author", "[deleted]"),
|
|
156
|
+
"body": d.get("body", ""),
|
|
157
|
+
"score": d.get("score", 0),
|
|
158
|
+
"created": _ts(d.get("created_utc")),
|
|
159
|
+
"is_submitter": d.get("is_submitter", False),
|
|
160
|
+
"depth": d.get("depth", 0),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_subreddit_info(data: dict) -> dict:
|
|
165
|
+
"""Extract key fields from a subreddit (t5)."""
|
|
166
|
+
d = data.get("data", data)
|
|
167
|
+
return {
|
|
168
|
+
"name": d.get("display_name", ""),
|
|
169
|
+
"title": d.get("title", ""),
|
|
170
|
+
"description": d.get("public_description", ""),
|
|
171
|
+
"subscribers": d.get("subscribers", 0),
|
|
172
|
+
"active_users": d.get("active_user_count") or d.get("accounts_active", 0),
|
|
173
|
+
"created": _ts(d.get("created_utc")),
|
|
174
|
+
"over_18": d.get("over18", False),
|
|
175
|
+
"type": d.get("subreddit_type", "public"),
|
|
176
|
+
"url": f"https://www.reddit.com/r/{d.get('display_name', '')}",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def format_subreddit_search(child: dict) -> dict:
|
|
181
|
+
"""Extract key fields from a subreddit search result."""
|
|
182
|
+
d = child.get("data", child)
|
|
183
|
+
return {
|
|
184
|
+
"name": d.get("display_name", ""),
|
|
185
|
+
"title": d.get("title", ""),
|
|
186
|
+
"description": (d.get("public_description") or "")[:100],
|
|
187
|
+
"subscribers": d.get("subscribers", 0),
|
|
188
|
+
"over_18": d.get("over18", False),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def format_user_info(data: dict) -> dict:
|
|
193
|
+
"""Extract key fields from a user (t2)."""
|
|
194
|
+
d = data.get("data", data)
|
|
195
|
+
return {
|
|
196
|
+
"name": d.get("name", ""),
|
|
197
|
+
"link_karma": d.get("link_karma", 0),
|
|
198
|
+
"comment_karma": d.get("comment_karma", 0),
|
|
199
|
+
"total_karma": d.get("total_karma", 0),
|
|
200
|
+
"created": _ts(d.get("created_utc")),
|
|
201
|
+
"is_gold": d.get("is_gold", False),
|
|
202
|
+
"verified": d.get("has_verified_email", False),
|
|
203
|
+
"url": f"https://www.reddit.com/user/{d.get('name', '')}",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_listing_posts(response: dict) -> tuple[list[dict], str | None]:
|
|
208
|
+
"""Extract posts from a Listing response. Returns (posts, after_cursor)."""
|
|
209
|
+
data = response.get("data", {})
|
|
210
|
+
children = data.get("children", [])
|
|
211
|
+
posts = [format_post_summary(c) for c in children if c.get("kind") == "t3"]
|
|
212
|
+
after = data.get("after")
|
|
213
|
+
return posts, after
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def extract_listing_posts_and_comments(response: dict) -> tuple[list[dict], list[dict], str | None]:
|
|
217
|
+
"""Extract posts AND comments from a mixed Listing (e.g., saved items).
|
|
218
|
+
|
|
219
|
+
Returns (posts, comments, after_cursor).
|
|
220
|
+
"""
|
|
221
|
+
data = response.get("data", {})
|
|
222
|
+
children = data.get("children", [])
|
|
223
|
+
posts = [format_post_summary(c) for c in children if c.get("kind") == "t3"]
|
|
224
|
+
comments = [format_saved_comment(c) for c in children if c.get("kind") == "t1"]
|
|
225
|
+
after = data.get("after")
|
|
226
|
+
return posts, comments, after
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_saved_comment(child: dict) -> dict:
|
|
230
|
+
"""Format a saved comment (t1) with subreddit and permalink."""
|
|
231
|
+
d = child.get("data", child)
|
|
232
|
+
comment = format_comment(child)
|
|
233
|
+
comment["subreddit"] = d.get("subreddit", "")
|
|
234
|
+
comment["permalink"] = f"https://www.reddit.com{d.get('permalink', '')}"
|
|
235
|
+
return comment
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def extract_listing_comments(response: dict) -> tuple[list[dict], str | None]:
|
|
239
|
+
"""Extract comments from a Listing response."""
|
|
240
|
+
data = response.get("data", {})
|
|
241
|
+
children = data.get("children", [])
|
|
242
|
+
comments = [format_comment(c) for c in children if c.get("kind") == "t1"]
|
|
243
|
+
after = data.get("after")
|
|
244
|
+
return comments, after
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def extract_listing_subreddits(response: dict) -> tuple[list[dict], str | None]:
|
|
248
|
+
"""Extract subreddits from a Listing response."""
|
|
249
|
+
data = response.get("data", {})
|
|
250
|
+
children = data.get("children", [])
|
|
251
|
+
subs = [format_subreddit_search(c) for c in children if c.get("kind") == "t5"]
|
|
252
|
+
after = data.get("after")
|
|
253
|
+
return subs, after
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""cli-web-reddit — CLI for Reddit browsing, search, and interaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# Windows UTF-8 fix
|
|
9
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
10
|
+
if _stream.encoding and _stream.encoding.lower() not in ("utf-8", "utf8"):
|
|
11
|
+
try:
|
|
12
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
13
|
+
except AttributeError:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from .commands.actions import comment_group, saved_group, submit, vote
|
|
19
|
+
from .commands.auth_cmd import auth
|
|
20
|
+
from .commands.feed import feed
|
|
21
|
+
from .commands.me import me
|
|
22
|
+
from .commands.post import post
|
|
23
|
+
from .commands.search import search
|
|
24
|
+
from .commands.subreddit import sub
|
|
25
|
+
from .commands.user import user
|
|
26
|
+
from .utils.repl_skin import ReplSkin
|
|
27
|
+
|
|
28
|
+
_skin = ReplSkin("reddit", version="0.1.0")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group(invoke_without_command=True)
|
|
32
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
33
|
+
@click.option("--version", is_flag=True, help="Show version.")
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def cli(ctx, json_mode, version):
|
|
36
|
+
"""Reddit browsing, search, and interaction CLI."""
|
|
37
|
+
ctx.ensure_object(dict)
|
|
38
|
+
ctx.obj["json"] = json_mode
|
|
39
|
+
if version:
|
|
40
|
+
click.echo("cli-web-reddit 0.1.0")
|
|
41
|
+
return
|
|
42
|
+
if ctx.invoked_subcommand is None:
|
|
43
|
+
_repl(ctx)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
cli.add_command(feed)
|
|
47
|
+
cli.add_command(sub)
|
|
48
|
+
cli.add_command(search)
|
|
49
|
+
cli.add_command(user)
|
|
50
|
+
cli.add_command(post)
|
|
51
|
+
cli.add_command(auth)
|
|
52
|
+
cli.add_command(me)
|
|
53
|
+
cli.add_command(vote)
|
|
54
|
+
cli.add_command(submit)
|
|
55
|
+
cli.add_command(comment_group, "comment")
|
|
56
|
+
cli.add_command(saved_group, "saved")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_repl_help():
|
|
60
|
+
"""Print REPL help listing all commands and key options."""
|
|
61
|
+
_skin.info("Available commands:")
|
|
62
|
+
print()
|
|
63
|
+
_skin.section("Browse (no login needed)")
|
|
64
|
+
print(" feed hot [--limit N] [--after CURSOR]")
|
|
65
|
+
print(" feed new [--limit N] [--after CURSOR]")
|
|
66
|
+
print(" feed top [--time hour|day|week|month|year|all] [--limit N]")
|
|
67
|
+
print(" feed rising [--limit N] [--after CURSOR]")
|
|
68
|
+
print(" feed popular [--limit N] [--after CURSOR]")
|
|
69
|
+
print()
|
|
70
|
+
print(" sub hot <name> [--limit N] [--after CURSOR]")
|
|
71
|
+
print(" sub new <name> [--limit N] [--after CURSOR]")
|
|
72
|
+
print(" sub top <name> [--time day|week|month|year|all] [--limit N]")
|
|
73
|
+
print(" sub info <name> Subreddit details and stats")
|
|
74
|
+
print(" sub rules <name> Subreddit rules")
|
|
75
|
+
print(" sub search <name> <query> [--sort relevance|hot|top|new|comments] [--limit N]")
|
|
76
|
+
print()
|
|
77
|
+
print(" search posts <query> [--sort relevance|hot|top|new|comments]")
|
|
78
|
+
print(" [--time hour|day|week|month|year|all] [--limit N]")
|
|
79
|
+
print(" search subs <query> [--limit N] [--after CURSOR]")
|
|
80
|
+
print()
|
|
81
|
+
print(" user info <username> User profile")
|
|
82
|
+
print(" user posts <username> [--sort hot|new|top] [--limit N]")
|
|
83
|
+
print(" user comments <username> [--sort hot|new|top] [--limit N]")
|
|
84
|
+
print()
|
|
85
|
+
print(" post get <url_or_id> [--sub <name>] [--comments N]")
|
|
86
|
+
print()
|
|
87
|
+
_skin.section("Account (login required)")
|
|
88
|
+
print(" auth login Open browser to log in")
|
|
89
|
+
print(" auth logout Remove saved credentials")
|
|
90
|
+
print(" auth status Check login status")
|
|
91
|
+
print()
|
|
92
|
+
print(" me profile Your Reddit profile")
|
|
93
|
+
print(" me saved [--limit N] Your saved posts")
|
|
94
|
+
print(" me upvoted [--limit N] Your upvoted posts")
|
|
95
|
+
print(" me subscriptions Your subscribed subreddits")
|
|
96
|
+
print(" me inbox [--limit N] Your inbox messages")
|
|
97
|
+
print()
|
|
98
|
+
print(" vote up <id> Upvote post/comment (t3_xxx or t1_xxx)")
|
|
99
|
+
print(" vote down <id> Downvote post/comment")
|
|
100
|
+
print(" vote unvote <id> Remove vote")
|
|
101
|
+
print()
|
|
102
|
+
print(" submit flairs <sub> List available post flairs")
|
|
103
|
+
print(" submit text <sub> <title> <body> Submit a text post [--flair ID]")
|
|
104
|
+
print(" submit link <sub> <title> <url> Submit a link post [--flair ID]")
|
|
105
|
+
print()
|
|
106
|
+
print(" comment add <id> <text> Comment on a post or reply to comment")
|
|
107
|
+
print(" comment edit <id> <text> Edit your post/comment")
|
|
108
|
+
print(" comment delete <id> Delete your post/comment")
|
|
109
|
+
print()
|
|
110
|
+
print(" saved save <id> Save a post/comment")
|
|
111
|
+
print(" saved unsave <id> Unsave a post/comment")
|
|
112
|
+
print()
|
|
113
|
+
print(" sub join <name> Subscribe to subreddit")
|
|
114
|
+
print(" sub leave <name> Unsubscribe from subreddit")
|
|
115
|
+
print()
|
|
116
|
+
print(" help Show this help")
|
|
117
|
+
print(" quit / exit Exit REPL")
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _repl(ctx):
|
|
122
|
+
"""Interactive REPL mode."""
|
|
123
|
+
_skin.print_banner()
|
|
124
|
+
pt_session = _skin.create_prompt_session()
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
line = _skin.get_input(pt_session)
|
|
129
|
+
if not line:
|
|
130
|
+
continue
|
|
131
|
+
if line.lower() in ("quit", "exit", "q"):
|
|
132
|
+
_skin.print_goodbye()
|
|
133
|
+
break
|
|
134
|
+
if line.lower() in ("help", "?"):
|
|
135
|
+
_print_repl_help()
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
args = shlex.split(line)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
_skin.error(f"Invalid input: {exc}")
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
repl_args = ["--json"] + args if ctx.obj.get("json") else args
|
|
145
|
+
try:
|
|
146
|
+
cli.main(args=repl_args, standalone_mode=False)
|
|
147
|
+
except SystemExit:
|
|
148
|
+
pass
|
|
149
|
+
except click.exceptions.UsageError as exc:
|
|
150
|
+
_skin.error(str(exc))
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
_skin.error(str(exc))
|
|
153
|
+
|
|
154
|
+
except (KeyboardInterrupt, EOFError):
|
|
155
|
+
_skin.print_goodbye()
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
cli()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# MCP server mode — exposes every command as an MCP tool over stdio.
|
|
164
|
+
# Canonical adapter: cli-web-core/cli_web_core/mcp_server.py (vendored copy).
|
|
165
|
+
from cli_web.reddit import __version__ as _pkg_version # noqa: E402
|
|
166
|
+
from cli_web.reddit.utils.doctor import register_doctor_command # noqa: E402
|
|
167
|
+
from cli_web.reddit.utils.mcp_server import register_mcp_command # noqa: E402
|
|
168
|
+
|
|
169
|
+
register_mcp_command(cli, app_name="reddit", version=_pkg_version)
|
|
170
|
+
register_doctor_command(cli, app_name="reddit", pkg="reddit")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reddit-cli
|
|
3
|
+
description: Use cli-web-reddit to browse Reddit feeds, subreddits, search posts, view user profiles, and (with auth) vote, comment, submit posts, save items, and manage subscriptions. Always prefer cli-web-reddit over manually fetching the Reddit website.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# cli-web-reddit
|
|
7
|
+
|
|
8
|
+
Reddit CLI — browse feeds, subreddits, search, user profiles, and full write operations with auth.
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install cli-web-reddit
|
|
14
|
+
cli-web-reddit feed hot --limit 5 --json
|
|
15
|
+
cli-web-reddit search posts "python async" --json
|
|
16
|
+
cli-web-reddit sub info python --json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
### Feed (no auth)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cli-web-reddit feed hot [--limit N] [--after CURSOR] --json
|
|
25
|
+
cli-web-reddit feed new [--limit N] [--after CURSOR] --json
|
|
26
|
+
cli-web-reddit feed top [--time hour|day|week|month|year|all] [--limit N] --json
|
|
27
|
+
cli-web-reddit feed rising [--limit N] --json
|
|
28
|
+
cli-web-reddit feed popular [--limit N] --json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Output: `{"posts": [{"id", "title", "author", "subreddit", "score", "num_comments", "url", "permalink", "flair", "created"}], "after": "cursor"}`
|
|
32
|
+
|
|
33
|
+
### Subreddit (no auth, join/leave require auth)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cli-web-reddit sub hot <name> [--limit N] --json
|
|
37
|
+
cli-web-reddit sub new <name> [--limit N] --json
|
|
38
|
+
cli-web-reddit sub top <name> [--time day|week|month|year|all] --json
|
|
39
|
+
cli-web-reddit sub info <name> --json
|
|
40
|
+
cli-web-reddit sub rules <name> --json
|
|
41
|
+
cli-web-reddit sub search <name> <query> [--sort relevance|hot|top|new] --json
|
|
42
|
+
cli-web-reddit sub join <name> --json # requires auth
|
|
43
|
+
cli-web-reddit sub leave <name> --json # requires auth
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Search (no auth)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cli-web-reddit search posts <query> [--sort relevance|hot|top|new] [--time hour|day|week] --json
|
|
50
|
+
cli-web-reddit search subs <query> [--limit N] --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### User (no auth)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cli-web-reddit user info <username> --json
|
|
57
|
+
cli-web-reddit user posts <username> [--sort hot|new|top] [--limit N] --json
|
|
58
|
+
cli-web-reddit user comments <username> [--sort hot|new|top] [--limit N] --json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Post Detail (no auth)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cli-web-reddit post get <url_or_id> [--sub <name>] [--comments N] --json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Fetches all comments including deeply nested threads (automatically expands
|
|
68
|
+
"continue this thread" and collapsed comment chains via Reddit's morechildren API).
|
|
69
|
+
Accepts full URLs, short IDs, or `t3_` prefixed fullnames.
|
|
70
|
+
|
|
71
|
+
### Auth
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cli-web-reddit auth login # opens browser, extracts token_v2
|
|
75
|
+
cli-web-reddit auth status --json
|
|
76
|
+
cli-web-reddit auth logout
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Vote (requires auth)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
cli-web-reddit vote up <thing_id> --json # t3_xxx or t1_xxx
|
|
83
|
+
cli-web-reddit vote down <thing_id> --json
|
|
84
|
+
cli-web-reddit vote unvote <thing_id> --json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Submit (requires auth)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cli-web-reddit submit flairs <subreddit> --json # list available flairs
|
|
91
|
+
cli-web-reddit submit text <subreddit> <title> <body> [--flair ID] --json
|
|
92
|
+
cli-web-reddit submit link <subreddit> <title> <url> [--flair ID] --json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `submit flairs` first to get flair IDs when a subreddit requires flair.
|
|
96
|
+
|
|
97
|
+
### Comment (requires auth)
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cli-web-reddit comment add <thing_id> <text> --json
|
|
101
|
+
cli-web-reddit comment edit <thing_id> <text> --json
|
|
102
|
+
cli-web-reddit comment delete <thing_id> --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Save (requires auth)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
cli-web-reddit saved save <thing_id> --json
|
|
109
|
+
cli-web-reddit saved unsave <thing_id> --json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Me (requires auth)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cli-web-reddit me profile --json
|
|
116
|
+
cli-web-reddit me saved [--limit N] --json
|
|
117
|
+
cli-web-reddit me upvoted [--limit N] --json
|
|
118
|
+
cli-web-reddit me subscriptions --json
|
|
119
|
+
cli-web-reddit me inbox [--limit N] --json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Agent Patterns
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Get trending posts from a subreddit
|
|
126
|
+
cli-web-reddit sub top python --time week --limit 10 --json | jq '.posts[] | {title, score, url}'
|
|
127
|
+
|
|
128
|
+
# Search and get post details
|
|
129
|
+
cli-web-reddit search posts "fastapi tutorial" --limit 3 --json
|
|
130
|
+
|
|
131
|
+
# Check a user's activity
|
|
132
|
+
cli-web-reddit user posts spez --limit 5 --json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Notes
|
|
136
|
+
|
|
137
|
+
- Public read commands work without auth (uses Reddit's .json API with curl_cffi)
|
|
138
|
+
- Write operations (vote, comment, submit, save) require `auth login` first
|
|
139
|
+
- Auth uses `token_v2` cookie extracted via Playwright browser login
|
|
140
|
+
- **Token auto-refresh**: when `token_v2` expires (~15-30 min), the CLI silently launches a headless browser with the saved profile to refresh it — no manual re-login needed
|
|
141
|
+
- HTTP 403 on specific endpoints (e.g., flair fetch on some subreddits) is correctly reported as "Permission denied" rather than "AUTH_EXPIRED"
|
|
142
|
+
- Rate limits: ~60 requests/minute for public API, ~600/minute with OAuth
|
|
143
|
+
- All commands support `--json` for structured output
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# TEST.md — cli-web-reddit Test Plan & Results
|
|
2
|
+
|
|
3
|
+
## Part 1: Test Plan
|
|
4
|
+
|
|
5
|
+
### Test Inventory
|
|
6
|
+
|
|
7
|
+
| File | Tests | Layer |
|
|
8
|
+
|------|-------|-------|
|
|
9
|
+
| `test_core.py` | 47 | Unit (mocked HTTP) + Click integration |
|
|
10
|
+
| `test_e2e.py` | 19 | Live API + subprocess |
|
|
11
|
+
| **Total** | **66** | |
|
|
12
|
+
|
|
13
|
+
### Unit Tests (test_core.py)
|
|
14
|
+
|
|
15
|
+
**TestExceptionHierarchy (9 tests)**
|
|
16
|
+
- All exceptions inherit from RedditError
|
|
17
|
+
- RateLimitError has retry_after (defaults to None)
|
|
18
|
+
- ServerError has status_code (defaults to 500)
|
|
19
|
+
|
|
20
|
+
**TestClientErrorMapping (8 tests)**
|
|
21
|
+
- 404 → NotFoundError
|
|
22
|
+
- 429 → RateLimitError (with and without retry-after header)
|
|
23
|
+
- 500/503 → ServerError with status_code
|
|
24
|
+
- Connection error → NetworkError
|
|
25
|
+
- Successful JSON response parsing
|
|
26
|
+
- Generic 4xx → RedditError
|
|
27
|
+
|
|
28
|
+
**TestModels (9 tests)**
|
|
29
|
+
- format_post_summary: extracts fields, handles missing data
|
|
30
|
+
- format_subreddit_info: subscribers, name, type
|
|
31
|
+
- format_user_info: karma, verification
|
|
32
|
+
- format_comment: body, depth, is_submitter
|
|
33
|
+
- extract_listing_posts: pagination cursor, filters non-t3 children, empty listings
|
|
34
|
+
|
|
35
|
+
**TestHelpers (14 tests)**
|
|
36
|
+
- json_error format with extra fields
|
|
37
|
+
- truncate: short, long, None, empty string
|
|
38
|
+
- handle_errors exit codes: NotFoundError→1, ServerError→2, NetworkError→2, RateLimitError→1, KeyboardInterrupt→130
|
|
39
|
+
- JSON mode error output for each exception type
|
|
40
|
+
|
|
41
|
+
**TestCLIClick (7 tests)**
|
|
42
|
+
- --version flag
|
|
43
|
+
- --help shows all command groups
|
|
44
|
+
- feed hot --json with mocked client
|
|
45
|
+
- feed new --json with mocked client
|
|
46
|
+
- search posts --json with mocked client
|
|
47
|
+
- JSON error output on NotFoundError
|
|
48
|
+
- JSON error output on NetworkError
|
|
49
|
+
|
|
50
|
+
### E2E Tests (test_e2e.py)
|
|
51
|
+
|
|
52
|
+
**TestFeedLive (4 tests)**
|
|
53
|
+
- feed hot: verify posts with required fields (id, title, author, score)
|
|
54
|
+
- feed top: time filter works (day)
|
|
55
|
+
- feed popular: returns posts from r/popular
|
|
56
|
+
- pagination: cursor-based pagination returns different posts
|
|
57
|
+
|
|
58
|
+
**TestSubredditLive (4 tests)**
|
|
59
|
+
- sub posts: fetch r/python posts
|
|
60
|
+
- sub info: verify name, subscribers, type fields
|
|
61
|
+
- sub rules: verify rules list returned
|
|
62
|
+
- list-get roundtrip: list posts → get one by ID → verify fields match
|
|
63
|
+
|
|
64
|
+
**TestSearchLive (2 tests)**
|
|
65
|
+
- search posts: keyword search returns results
|
|
66
|
+
- search subreddits: subreddit search returns results with names
|
|
67
|
+
|
|
68
|
+
**TestUserLive (2 tests)**
|
|
69
|
+
- user about: verify u/spez profile fields
|
|
70
|
+
- user posts: verify user submissions returned
|
|
71
|
+
|
|
72
|
+
**TestCLISubprocess (7 tests)**
|
|
73
|
+
- --help: shows all command groups
|
|
74
|
+
- --version: shows 0.1.0
|
|
75
|
+
- feed hot --json: valid JSON with posts array
|
|
76
|
+
- search posts --json: search results with query
|
|
77
|
+
- sub info --json: subreddit details
|
|
78
|
+
- user info --json: user profile
|
|
79
|
+
- Human-readable table output (non-JSON)
|
|
80
|
+
|
|
81
|
+
Subprocess tests use `_resolve_cli("cli-web-reddit")` pattern with Windows UTF-8 encoding.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Part 2: Test Results
|
|
86
|
+
|
|
87
|
+
**Date:** 2026-03-24
|
|
88
|
+
**Environment:** Windows 11, Python 3.12.8, curl_cffi
|
|
89
|
+
|
|
90
|
+
### Unit Tests
|
|
91
|
+
```
|
|
92
|
+
47 passed in 0.57s
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### E2E + Subprocess Tests
|
|
96
|
+
```
|
|
97
|
+
19 passed in 19.14s
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Summary
|
|
101
|
+
|
|
102
|
+
| Metric | Value |
|
|
103
|
+
|--------|-------|
|
|
104
|
+
| Total tests | 66 |
|
|
105
|
+
| Passed | 66 |
|
|
106
|
+
| Failed | 0 |
|
|
107
|
+
| Pass rate | 100% |
|
|
108
|
+
| Unit test time | 0.57s |
|
|
109
|
+
| E2E test time | 19.14s |
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Pytest configuration — register custom markers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def pytest_configure(config):
|
|
5
|
+
config.addinivalue_line("markers", "unit: Fast unit tests (mocked HTTP, no network)")
|
|
6
|
+
config.addinivalue_line("markers", "live: Live API tests that hit the real Reddit API")
|
|
7
|
+
config.addinivalue_line(
|
|
8
|
+
"markers", "subprocess: Subprocess tests that invoke the installed CLI binary"
|
|
9
|
+
)
|