diff --git a/AI_API.md b/AI_API.md index fae9b7a..53ae4df 100644 --- a/AI_API.md +++ b/AI_API.md @@ -18,6 +18,12 @@ All requests/responses are JSON. Send `Accept: application/json` (Laravel defaults to JSON error responses for API routes regardless, but set it to be safe on validation-error bodies). +**If you're an agent running on the machine this app is deployed to**, you +don't need to build HTTP requests by hand — use the CLI wrappers in +`python_scripts/` (`sites.py`, `accounts.py`, `posts.py`; see +`python_scripts/README.md`). They default to `http://localhost:8001` and +print the same JSON shapes documented below. + ## Mental model ``` diff --git a/python_scripts/README.md b/python_scripts/README.md new file mode 100644 index 0000000..544f476 --- /dev/null +++ b/python_scripts/README.md @@ -0,0 +1,47 @@ +# Muninn API scripts + +Thin Python CLI wrappers around this app's JSON API (see `../AI_API.md` for +the full API reference). Meant to be called directly by an AI agent on the +machine where this app is reachable — no setup beyond Python 3's standard +library (no third-party dependencies). + +Default base URL: `http://localhost:8001`. Override with the +`MUNINN_API_URL` environment variable if the app runs elsewhere or on a +different port. + +Every command prints the API's JSON response to stdout and exits `0` on +success. On an HTTP error or if the server can't be reached, it prints a +JSON error object to stderr and exits `1`. + +## Sites (read-only) + +``` +python3 sites.py list [--name NAME] +python3 sites.py get SITE_ID +``` + +## Social Accounts (read-only) + +``` +python3 accounts.py list [--network NETWORK] [--type TYPE] +python3 accounts.py get ACCOUNT_ID +``` + +## Posts (full CRUD) + +``` +python3 posts.py list [--status STATUS] [--site-id ID] [--social-account-id ID] [--due] +python3 posts.py get POST_ID +python3 posts.py create --site-id ID --type TYPE --body BODY [--social-account-id ID] [--title TITLE] [--status STATUS] [--scheduled-at DATETIME] +python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID] [--type TYPE] [--title TITLE] [--body BODY] [--status STATUS] [--scheduled-at DATETIME] [--published-url URL] +python3 posts.py delete POST_ID +``` + +`--due` finds posts that are `scheduled` and past their `scheduled_at` — +the query to run to discover what needs publishing right now. + +Publishing itself is out of scope for these scripts (they only wrap the +HTTP API). See `../AI_API.md` for how to actually publish a post +(`php artisan posts:publish` for WordPress, the `sidecars/reddit-post` +sidecar for Reddit, or your own method for anything else — then report +back with `posts.py update POST_ID --status ...`). diff --git a/python_scripts/__pycache__/_client.cpython-310.pyc b/python_scripts/__pycache__/_client.cpython-310.pyc new file mode 100644 index 0000000..bc3c7a8 Binary files /dev/null and b/python_scripts/__pycache__/_client.cpython-310.pyc differ diff --git a/python_scripts/_client.py b/python_scripts/_client.py new file mode 100755 index 0000000..649bbc9 --- /dev/null +++ b/python_scripts/_client.py @@ -0,0 +1,62 @@ +"""Shared HTTP helper for the Muninn API scripts. + +Not meant to be run directly - imported by sites.py, accounts.py, posts.py. +""" + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_BASE_URL = "http://localhost:8001" + + +def base_url() -> str: + return os.environ.get("MUNINN_API_URL", DEFAULT_BASE_URL).rstrip("/") + + +def request(method: str, path: str, params: dict | None = None, body: dict | None = None) -> None: + """Call the API and print the JSON response to stdout. + + Exits 0 on a 2xx response. On an HTTP error or network failure, prints + a JSON error object to stderr and exits 1. + """ + url = f"{base_url()}{path}" + + if params: + query = "&".join( + f"{key}={urllib.parse.quote(str(value))}" + for key, value in params.items() + if value is not None + ) + if query: + url = f"{url}?{query}" + + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, method=method, headers=headers) + + try: + with urllib.request.urlopen(req) as response: + payload = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body_text = error.read().decode("utf-8") + try: + parsed = json.loads(body_text) + except json.JSONDecodeError: + parsed = {"error": body_text} + print(json.dumps(parsed, indent=2), file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as error: + print(json.dumps({"error": f"could not reach {url}: {error.reason}"}, indent=2), file=sys.stderr) + sys.exit(1) + + if payload: + parsed = json.loads(payload) + print(json.dumps(parsed, indent=2)) + sys.exit(0) diff --git a/python_scripts/accounts.py b/python_scripts/accounts.py new file mode 100755 index 0000000..5465f70 --- /dev/null +++ b/python_scripts/accounts.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Read Social Accounts from the Muninn API. + +Usage: + python3 accounts.py list [--network NETWORK] [--type TYPE] + python3 accounts.py get ACCOUNT_ID + +Base URL defaults to http://localhost:8001, override with the +MUNINN_API_URL environment variable. +""" + +import argparse + +import _client + +NETWORKS = ["twitter", "instagram", "facebook", "reddit", "tiktok", "linkedin", "youtube", "other"] +TYPES = ["post", "comment", "like"] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Social Accounts API client") + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List all social accounts") + list_parser.add_argument("--network", choices=NETWORKS, help="Exact match filter on network") + list_parser.add_argument("--type", choices=TYPES, help="Exact match filter on type") + + get_parser = subparsers.add_parser("get", help="Get a single social account") + get_parser.add_argument("account_id", type=int) + + args = parser.parse_args() + + if args.command == "list": + _client.request("GET", "/api/accounts", params={"network": args.network, "type": args.type}) + elif args.command == "get": + _client.request("GET", f"/api/accounts/{args.account_id}") + + +if __name__ == "__main__": + main() diff --git a/python_scripts/posts.py b/python_scripts/posts.py new file mode 100755 index 0000000..23cd8dc --- /dev/null +++ b/python_scripts/posts.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Read and write Posts on the Muninn API. + +Usage: + python3 posts.py list [--status STATUS] [--site-id ID] [--social-account-id ID] [--due] + python3 posts.py get POST_ID + python3 posts.py create --site-id ID --type TYPE --body BODY + [--social-account-id ID] [--title TITLE] + [--status STATUS] [--scheduled-at DATETIME] + python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID] + [--type TYPE] [--title TITLE] [--body BODY] + [--status STATUS] [--scheduled-at DATETIME] + [--published-url URL] + python3 posts.py delete POST_ID + +Base URL defaults to http://localhost:8001, override with the +MUNINN_API_URL environment variable. +""" + +import argparse + +import _client + +TYPES = ["twitter", "instagram", "facebook", "reddit", "tiktok", "linkedin", "youtube", "blog", "other"] +STATUSES = ["draft", "scheduled", "published", "failed"] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Posts API client") + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List posts") + list_parser.add_argument("--status", choices=STATUSES) + list_parser.add_argument("--site-id", type=int) + list_parser.add_argument("--social-account-id", type=int) + list_parser.add_argument( + "--due", action="store_true", help="Only posts that are scheduled and due now" + ) + + get_parser = subparsers.add_parser("get", help="Get a single post") + get_parser.add_argument("post_id", type=int) + + create_parser = subparsers.add_parser("create", help="Create a post") + create_parser.add_argument("--site-id", type=int, required=True) + create_parser.add_argument("--type", choices=TYPES, required=True) + create_parser.add_argument("--body", required=True) + create_parser.add_argument("--social-account-id", type=int) + create_parser.add_argument("--title") + create_parser.add_argument("--status", choices=STATUSES) + create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00") + + update_parser = subparsers.add_parser("update", help="Update a post") + update_parser.add_argument("post_id", type=int) + update_parser.add_argument("--site-id", type=int) + update_parser.add_argument("--social-account-id", type=int) + update_parser.add_argument("--type", choices=TYPES) + update_parser.add_argument("--title") + update_parser.add_argument("--body") + update_parser.add_argument("--status", choices=STATUSES) + update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime") + update_parser.add_argument("--published-url") + + delete_parser = subparsers.add_parser("delete", help="Delete a post") + delete_parser.add_argument("post_id", type=int) + + args = parser.parse_args() + + if args.command == "list": + params = { + "status": args.status, + "site_id": args.site_id, + "social_account_id": args.social_account_id, + } + if args.due: + params["due"] = 1 + _client.request("GET", "/api/posts", params=params) + + elif args.command == "get": + _client.request("GET", f"/api/posts/{args.post_id}") + + elif args.command == "create": + body = { + "site_id": args.site_id, + "social_account_id": args.social_account_id, + "type": args.type, + "title": args.title, + "body": args.body, + "status": args.status, + "scheduled_at": args.scheduled_at, + } + body = {key: value for key, value in body.items() if value is not None} + _client.request("POST", "/api/posts", body=body) + + elif args.command == "update": + body = { + "site_id": args.site_id, + "social_account_id": args.social_account_id, + "type": args.type, + "title": args.title, + "body": args.body, + "status": args.status, + "scheduled_at": args.scheduled_at, + "published_url": args.published_url, + } + body = {key: value for key, value in body.items() if value is not None} + _client.request("PATCH", f"/api/posts/{args.post_id}", body=body) + + elif args.command == "delete": + _client.request("DELETE", f"/api/posts/{args.post_id}") + + +if __name__ == "__main__": + main() diff --git a/python_scripts/sites.py b/python_scripts/sites.py new file mode 100755 index 0000000..800f388 --- /dev/null +++ b/python_scripts/sites.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Read Sites from the Muninn API. + +Usage: + python3 sites.py list [--name NAME] + python3 sites.py get SITE_ID + +Base URL defaults to http://localhost:8001, override with the +MUNINN_API_URL environment variable. +""" + +import argparse + +import _client + + +def main() -> None: + parser = argparse.ArgumentParser(description="Sites API client") + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List all sites") + list_parser.add_argument("--name", help="Partial match filter on name") + + get_parser = subparsers.add_parser("get", help="Get a single site") + get_parser.add_argument("site_id", type=int) + + args = parser.parse_args() + + if args.command == "list": + _client.request("GET", "/api/sites", params={"name": args.name}) + elif args.command == "get": + _client.request("GET", f"/api/sites/{args.site_id}") + + +if __name__ == "__main__": + main()