#!/usr/bin/env python3 """Discourse CLI - a thin wrapper around the Discourse REST API. Connection details come from the environment: DISCOURSE_URL base URL of the Discourse instance (e.g. https://discourse.example.com) DISCOURSE_API_KEY API key ("All users" or "Single user" key from Admin > API) DISCOURSE_API_USERNAME username the API key acts as (e.g. system, or your account) Designed to be run inside the discourse-cli Docker container, but works anywhere these environment variables are set. """ import argparse import json import os import sys import requests # --------------------------------------------------------------------------- # # HTTP client # --------------------------------------------------------------------------- # class DiscourseAPI: """Minimal authenticated Discourse REST client.""" def __init__(self, url, api_key, api_username): self.base_url = url.rstrip("/") self.headers = { "Api-Key": api_key, "Api-Username": api_username, "Accept": "application/json", } self.timeout = 30 def _request(self, method, path, **kwargs): url = f"{self.base_url}/{path.lstrip('/')}" resp = requests.request( method, url, headers=self.headers, timeout=self.timeout, **kwargs ) if resp.status_code == 429: _err(f"rate limited by Discourse (HTTP 429). Retry later.") if not resp.ok: detail = "" try: detail = resp.json().get("errors", resp.text[:200]) except Exception: detail = resp.text[:200] _err(f"Discourse API error {resp.status_code} for {method} {path}: {detail}") if resp.status_code == 204 or not resp.content: return {} return resp.json() def get(self, path, params=None): return self._request("GET", path, params=params) def post(self, path, data=None): return self._request("POST", path, json=data) def put(self, path, data=None): return self._request("PUT", path, json=data) def delete(self, path, data=None): return self._request("DELETE", path, json=data) # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # def _client(): """Build and return an authenticated Discourse client, or exit with help.""" url = os.environ.get("DISCOURSE_URL", "").strip() key = os.environ.get("DISCOURSE_API_KEY", "").strip() user = os.environ.get("DISCOURSE_API_USERNAME", "").strip() missing = [ n for n, v in ( ("DISCOURSE_URL", url), ("DISCOURSE_API_KEY", key), ("DISCOURSE_API_USERNAME", user), ) if not v ] if missing: sys.stderr.write( "ERROR: missing required environment variable(s): " + ", ".join(missing) + "\n" ) sys.exit(2) return DiscourseAPI(url, key, user) def _err(msg, code=1): sys.stderr.write(f"ERROR: {msg}\n") sys.exit(code) def _kv(label, value): """Format a label/value line, omitting falsy values gracefully.""" if value in (None, "", [], {}): return None return f"{label:>14}: {value}" # --------------------------------------------------------------------------- # # Commands # --------------------------------------------------------------------------- # def cmd_whoami(args): api = _client() data = api.get("/session/current.json") u = data.get("current_user", {}) print(f"id: {u.get('id', '?')}") print(f"username: {u.get('username', '?')}") print(f"name: {u.get('name', '?')}") print(f"admin: {u.get('admin', False)}") print(f"trust: {u.get('trust_level', '?')}") print(f"discourse: {os.environ['DISCOURSE_URL']}") return 0 def cmd_categories(args): api = _client() data = api.get("/categories.json") cats = data.get("category_list", {}).get("categories", []) print(f"{'ID':<6} {'SLUG':<28} {'TOPICS':>7} NAME") print("-" * 80) for c in cats: topic_count = c.get("topic_count", 0) print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<28} {topic_count:>6} {c.get('name','')}") subcats = [] for c in cats: subcats.extend(c.get("subcategory_ids", []) or []) print(f"\n{len(cats)} category(ies)") return 0 def cmd_topics(args): api = _client() if args.category: cat_id = _resolve_category(api, args.category) data = api.get(f"/c/{cat_id}.json", params={"page": args.page}) topics = data.get("topic_list", {}).get("topics", []) else: path = "/latest.json" if args.unread: path = "/unread.json" elif args.new: path = "/new.json" data = api.get(path, params={"page": args.page}) topics = data.get("topic_list", {}).get("topics", []) print(f"{'ID':<10} {'REPLIES':>8} {'VIEWS':>9} TITLE") print("-" * 90) for t in topics: if t.get("pinned"): continue replies = t.get("posts_count", 1) - 1 views = t.get("views", 0) title = t.get("title", "") print(f"{t.get('id','?'):<10} {replies:>8} {views:>9} {title}") print(f"\n{len(topics)} topic(s)") return 0 def cmd_show(args): api = _client() data = api.get(f"/t/{args.topic_id}.json") print(f"#{data.get('id','?')}: {data.get('title','?')}") print("=" * 90) print(_kv("category_id", data.get("category_id"))) print(_kv("views", data.get("views"))) print(_kv("like_count", data.get("like_count"))) print(_kv("posts_count", data.get("posts_count"))) print(_kv("created", data.get("created_at"))) print(_kv("last_posted", data.get("last_posted_at"))) print("") posts = data.get("post_stream", {}).get("posts", []) for idx, p in enumerate(posts, start=1): post_num = p.get("post_number", idx) author = p.get("username", "?") created = p.get("created_at", "?") likes = p.get("actions_summary", []) like_count = 0 for a in likes: if a.get("id") == 2: like_count = a.get("count", 0) print(f"\n--- #{post_num} [{post_num}] {author} ({created}) likes={like_count} ---") cooked = p.get("cooked", "") text = _strip_html(cooked) if text.strip(): print(text.strip()) else: print("(no content)") return 0 def cmd_create(args): api = _client() if not args.title: _err("--title is required to create a topic") if not args.body: _err("--body is required to create a topic") cat_id = _resolve_category(api, args.category) if args.category else None payload = { "title": args.title, "raw": args.body, } if cat_id is not None: payload["category"] = cat_id if args.tags: payload["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()] data = api.post("/posts.json", data=payload) topic_id = data.get("topic_id", "?") post_id = data.get("id", "?") print(f"Created topic #{topic_id} (post #{post_id}): {args.title}") if topic_id != "?": print(f"URL: {os.environ['DISCOURSE_URL']}/t/{topic_id}") return 0 def cmd_reply(args): api = _client() if not args.body: _err("--body is required to reply") payload = {"topic_id": args.topic_id, "raw": args.body} if args.reply_to: payload["reply_to_post_number"] = args.reply_to data = api.post("/posts.json", data=payload) post_id = data.get("id", "?") post_num = data.get("post_number", "?") print(f"Posted reply #{post_num} (id={post_id}) in topic #{args.topic_id}") return 0 def cmd_update(args): api = _client() if not args.body: _err("--body is required to update a post") data = api.put(f"/posts/{args.post_id}.json", data={"post": {"raw": args.body}}) post_num = data.get("post", {}).get("post_number", "?") print(f"Updated post #{post_num} (id={args.post_id})") return 0 def cmd_delete(args): api = _client() api.delete(f"/posts/{args.post_id}.json") print(f"Deleted post #{args.post_id}") return 0 def cmd_search(args): api = _client() params = {"q": args.query, "page": args.page} data = api.get("/search.json", params=params) topics = data.get("topics", []) posts = data.get("posts", []) topic_map = {t.get("id"): t for t in topics} print(f"{'TOPIC':<10} {'POST':<8} BLURB") print("-" * 90) for p in posts: tid = p.get("topic_id", "?") t = topic_map.get(tid, {}) title = t.get("title", "") blurb = p.get("blurb", "") pid = p.get("id", "?") print(f"{tid:<10} {pid:<8} {title}") if blurb: print(f"{'':<20}{blurb}") print(f"\n{len(posts)} result(s)") return 0 def cmd_notifications(args): api = _client() data = api.get("/notifications.json") notifs = data.get("notifications", []) if not notifs: print("(no notifications)") return 0 print(f"{'ID':<10} {'READ':>5} TYPE SUBJECT") print("-" * 90) for n in notifs[: args.limit]: nid = n.get("id", "?") read = "yes" if n.get("read") else "no" ntype = n.get("notification_type", "?") subject = n.get("slug") or n.get("data", {}).get("topic_title", "") print(f"{nid:<10} {read:>5} {str(ntype):<20} {subject}") print(f"\n{len(notifs)} notification(s), showing {min(len(notifs), args.limit)}") return 0 def cmd_categories_info(args): api = _client() cat_id = _resolve_category(api, args.category) data = api.get(f"/c/{cat_id}/show.json") c = data.get("category", {}) print(f"#{c.get('id','?')}: {c.get('name','?')}") print("=" * 60) print(_kv("slug", c.get("slug"))) print(_kv("color", c.get("color"))) print(_kv("topic_count", c.get("topic_count"))) print(_kv("post_count", c.get("post_count"))) print(_kv("description", _strip_html(c.get("description", "") or "")[:200])) return 0 # --------------------------------------------------------------------------- # # Resolution helpers # --------------------------------------------------------------------------- # def _resolve_category(api, ref): """Resolve a category id, slug, or name to a numeric id.""" if str(ref).isdigit(): return int(ref) data = api.get("/categories.json") cats = data.get("category_list", {}).get("categories", []) needle = str(ref).strip().lower() for c in cats: if str(c.get("slug", "")).lower() == needle: return c["id"] if str(c.get("name", "")).lower() == needle: return c["id"] _err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.") def _strip_html(cooked): """Very small HTML-to-text for display of Discourse 'cooked' post bodies.""" import html import re if not cooked: return "" # Preserve block-level breaks text = re.sub(r"(?i)", "\n", cooked) text = re.sub(r"(?i)", "\n", text) text = re.sub(r"(?i)]*>", " - ", text) # Code blocks text = re.sub(r"(?i)]*>", "`", text) text = re.sub(r"(?i)", "`", text) # Strip all remaining tags text = re.sub(r"<[^>]+>", "", text) text = html.unescape(text) # Collapse excessive blank lines text = re.sub(r"\n{3,}", "\n\n", text) return text # --------------------------------------------------------------------------- # # Argument parsing # --------------------------------------------------------------------------- # def build_parser(): p = argparse.ArgumentParser( prog="discourse-cli", description="Read, post, and discuss on a Discourse forum via the REST API.", ) sub = p.add_subparsers(dest="command", required=True) sub.add_parser( "whoami", help="show the authenticated user (connection test)" ).set_defaults(func=cmd_whoami) sp = sub.add_parser("categories", help="list categories") sp.set_defaults(func=cmd_categories) sp = sub.add_parser("cat-info", help="show details of a category") sp.add_argument("category", metavar="ID_OR_SLUG") sp.set_defaults(func=cmd_categories_info) sp = sub.add_parser("topics", aliases=["ls"], help="list topics (latest or in a category)") sp.add_argument("-c", "--category", metavar="ID_OR_SLUG", help="filter by category") sp.add_argument("-n", "--new", action="store_true", help="only new topics") sp.add_argument("-u", "--unread", action="store_true", help="only unread topics") sp.add_argument("-p", "--page", type=int, default=0, help="page number (default 0)") sp.set_defaults(func=cmd_topics) sp = sub.add_parser("show", help="show a topic with its posts") sp.add_argument("topic_id", type=int) sp.set_defaults(func=cmd_show) sp = sub.add_parser("create", help="create a new topic") sp.add_argument("-c", "--category", metavar="ID_OR_SLUG") sp.add_argument("-t", "--title", required=True, help="topic title") sp.add_argument("-b", "--body", required=True, help="post body (raw text/markdown)") sp.add_argument("--tags", help="comma-separated tags") sp.set_defaults(func=cmd_create) sp = sub.add_parser("reply", help="reply to a topic") sp.add_argument("topic_id", type=int) sp.add_argument("-b", "--body", required=True, help="reply body (raw text/markdown)") sp.add_argument( "-r", "--reply-to", type=int, metavar="POST_NUMBER", help="reply to a specific post number" ) sp.set_defaults(func=cmd_reply) sp = sub.add_parser("update", help="update an existing post") sp.add_argument("post_id", type=int) sp.add_argument("-b", "--body", required=True, help="new post body (raw text/markdown)") sp.set_defaults(func=cmd_update) sp = sub.add_parser("delete", help="delete a post") sp.add_argument("post_id", type=int) sp.set_defaults(func=cmd_delete) sp = sub.add_parser("search", help="search the forum") sp.add_argument("query", help="search query") sp.add_argument("-p", "--page", type=int, default=1, help="page number (default 1)") sp.set_defaults(func=cmd_search) sp = sub.add_parser("notifications", help="list your notifications") sp.add_argument("-l", "--limit", type=int, default=20, help="max results (default 20)") sp.set_defaults(func=cmd_notifications) return p def main(argv=None): parser = build_parser() args = parser.parse_args(argv) try: return args.func(args) except requests.exceptions.ConnectionError: _err(f"could not connect to Discourse at {os.environ.get('DISCOURSE_URL','?')}", 2) except requests.exceptions.Timeout: _err("request timed out talking to Discourse", 2) except SystemExit: raise except Exception as e: # noqa: BLE001 - top-level safety net for the CLI _err(f"{type(e).__name__}: {e}") if __name__ == "__main__": sys.exit(main())