# mopac-discourse-go A 100% Go, stdlib-only client for the Discourse REST API (admin API key auth). One static binary, zero third-party modules, no official SDK — the MOPAC outbound-CLI for Discourse (SPEC-20260829 "supporting cast": Discourse is where briefings, usage reports and agent documentation land). AGPLv3. Status: 2026-08-29 — v0 complete and green: categories (list/create), topics (create/list/latest/get), posts (create/update/get), current-user identity probe, raw JSON passthrough for everything else, typed error classes, thin CLI. Built and tested entirely against a fake Discourse (no live writes). Live reads verified against community.turnsys.com; category creation 403s with the current key (needs the admin-scoped key) — see "Live verification" below. ## What it implements The wire protocol, in plain REST with stdlib: | Surface | Wire | |---|---| | whoami | `GET /session/current.json` — cheapest url+key+username check | | categories list | `GET /categories.json` | | categories create | `POST /categories.json` — name, color, text_color, permissions (group name -> 1/2/3); needs an admin-scoped key | | topics create | `POST /posts.json` — first post with `title`+`raw`+`category`; response carries `id` (first post), `topic_id`, `topic_slug` | | topics list | `GET /c/.json`, `GET /c/.json`, `GET /c//.json` — category topic lists | | topics latest | `GET /latest.json` | | topics get | `GET /t/.json` | | posts create | `POST /posts.json` — `topic_id`+`raw` | | posts update | `PUT /posts/.json` — `{post: {raw, edit_reason}}` | | posts get | `GET /posts/.json` | | raw anything | `Do(ctx, METHOD, path, body, out)` — JSON in, JSON out; unmodeled endpoints never block on a client release | Auth is the header pair `Api-Key` + `Api-Username` on every request. ## Library surface (what `harness brief` calls) ```go import "git.knownelement.com/ukrrs/mopac-discourse-go" c, err := discourse.New(baseURL, apiKey, apiUsername) // or NewFromEnv() cats, _ := c.ListCategories(ctx) cat, _ := c.FindCategory(ctx, 0, "mopac-briefings") // id OR slug cat, err := c.CreateCategory(ctx, discourse.CreateCategoryRequest{ Name: "MOPAC Briefings", Color: "3AB54A", TextColor: "FFFFFF", Permissions: map[string]int{"staff": 3}, }) // ErrForbidden until the admin-scoped key lands res, _ := c.CreateTopic(ctx, discourse.CreateTopicRequest{ Title: "MOPAC briefing 2026-09-01", Raw: markdown, Category: cat.ID, }) res.URL(baseURL) // https://forum/t// p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"}) _, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo") ``` Errors classify via `errors.Is`: `ErrForbidden` (403 — key lacks scope), `ErrUnauthorized` (401), `ErrNotFound`, `ErrRateLimited` (429, with `APIError.RetryAfter`), `ErrServer`, `ErrUnreachable`, `ErrMalformedResponse`, `ErrInvalidRequest`. Every server failure is also an `*APIError` carrying method, path, status and the Discourse-reported reasons. The key is constructor/env-only, never a flag, never logged: `Client.String()` renders base url + acting username, and error strings carry only paths and status codes (the redaction sweep in the smoke greps every captured output for the key). ## Quickstart (verified 2026-08-29, all against the fake server) All dev work happens inside a Docker builder (host stays toolchain-free). ```sh ./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm ``` Expected output (tail): ```text ok git.knownelement.com/ukrrs/mopac-discourse-go ``` End-to-end smoke — builds the CLI, boots the fake Discourse in a container on `127.0.0.1:8610`, drives the real binary from the host through a 0600 env file (whoami / category list+create / topic create / topic list by id and slug / post reply / post update / raw passthrough / typed 404 / redaction sweep / bad-key exit code): ```sh ./dev.sh smoke ``` Expected output (tail): ```text smoke: OK ``` ### Configure Credentials NEVER arrive via flags or arguments: ```sh mkdir -p ~/.config/discourse-go && umask 077 cp env.example ~/.config/discourse-go/env # fill in DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME ``` Then `set -a; . ~/.config/discourse-go/env; set +a` before the CLI (or source it in the service env). For the harness, the key resolves through its `key_ref` mechanism (env:/file:/mpk:) exactly like the Redmine key. ## CLI reference ``` discourse-go whoami discourse-go categories list discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]... discourse-go topics list [-category ID] [-slug SLUG] [-latest] discourse-go topics show ID discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT] discourse-go posts create -topic ID [-file PATH | -raw TEXT] discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT] discourse-go raw METHOD PATH [-data JSON|@file] ``` Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON. Exit codes: 0 ok, 1 usage/config, 2 API/transport error (the message names the class). ## Live verification (community.turnsys.com, 2026-08-29) - `whoami` + `categories list` with the current user-scoped key: OK (HTTP 200). - `categories create`: **HTTP 403** with the current key — category creation is admin-scoped. The client fully supports it and maps it to `ErrForbidden`; live creation waits for the admin key (Charles's 1900 list). The harness briefing pipeline is unaffected: it posts TOPICS into an existing category, which the current key allows. ## License AGPLv3 (see LICENSE) — maximally viral, per the MOPAC spec.