A user-centric command-line client for any Discourse instance, packaged as a .NET tool. It is read-first: the only write path is draft creation, which always hands off to a browser where a human reviews and clicks to publish.
This tool never publishes directly and cannot. Every mutation passes through a single choke-point (
DraftGate) whose whitelist contains only draft routes. There is no code anywhere in this repository that constructs a publish request. The worst an automated caller (or a hijacked credential) can do is stage a draft that a human must still approve in the browser.
It is intended for interactive desktop use and is deliberately unsuitable for headless/CI runtime automation (building/testing/packaging the project in CI is of course fine).
Once published to NuGet.org:
dotnet tool install --global Discourse.CliOr build and install from source:
dotnet pack src/disc/disc.csproj -c Release -o src/disc/nupkg
dotnet tool install --global --add-source ./src/disc/nupkg Discourse.Clidisc context add myforum --host forum.example.com # register a target
disc login # authorize (read scope)
disc list --top --json # popular topics
disc search --query "release notes" # search
disc read --topic 1234 # read a topic
disc categories # postable categoriesTo create drafts you must opt in to the write scope:
disc login --enable-drafts
disc draft --title "Hello" --category general --body "First post body"
# -> stores a draft and opens the browser. You click to publish.| Command | Scope | Description |
|---|---|---|
context add|use|list |
none | Manage named target forums |
login [--enable-drafts] |
read / read,write | User API Key browser flow |
logout |
none | Remove the local credential |
whoami |
read | Validate the stored credential |
status |
none | Show context, auth state, scopes |
list [--top] [--category <s>] [--page <n>] |
read | Topic lists |
search --query "<q>" [--page <n>] |
read | Keyword search |
read (--topic <id> | --post <id>) [--raw|--cooked] |
read | Read content |
categories [--all] |
read | Postable categories (permission == 1) |
draft ... [--tag a,b] |
write | Create a draft, hand off to browser |
skill [--output <path>] |
none | Generate agent-facing SKILL.md |
Global options: --forum <host>, --context <name>, --json, --help,
--version.
Every draft disc creates is marked as disc-authored, and this cannot be
disabled:
- the post body gets a footer crediting disc (with a link to this repo), and
- every new topic carries the
disctag.
--tag a,b adds your own tags alongside the mandatory disc tag. Because the
final publish happens in the browser (disc never publishes), these markers are
the provenance trail. For the disc tag to attach, the forum must permit that
tag — admins may want to pre-create a disc tag or allow tag creation.
The browser handoff opens a pre-filled composer (/new-topic?title=…&body=…&category_id=…&tags=…)
rather than a bare /new-topic, because a bare /new-topic does not reliably
restore a server-side draft (verified against a live forum). The server draft is
still saved and appears under My Posts → Drafts.
- Authentication uses a User API Key obtained via a browser flow (RSA-2048 keypair generated locally; only the public key leaves the machine; the encrypted callback is decrypted with the private key and the nonce verified).
- Default scope is
read;--enable-draftsrequestsread,write(Discourse has no draft-only sub-scope, so honesty about the trade-off matters — the safety property comes from the tool's discipline, not from the credential). - Credentials are stored only in the OS credential store — never a plaintext
file: Windows DPAPI (CurrentUser), macOS keychain (
security), Linux libsecret (secret-tool). Keys are never printed or logged.
0 success · 2 usage · 4 auth required · 5 scope insufficient ·
6 rate limited · 7 conflict · 8 forbidden · 9 not found · 10 network ·
11 API error · 1 general.
The User API Key flow requires a few Discourse site settings on the target
forum. Settings are edited in Admin → Settings (search by name). Without the
required ones, disc login cannot complete.
| Site setting | Value to set | Why |
|---|---|---|
allowed_user_api_auth_redirects |
add http://127.0.0.1:* |
The browser must redirect the encrypted key back to the CLI's loopback listener. If this does not match, Discourse returns 403 (rendered as "Oops! That page is private." / Forbidden) right after you authorize, the browser never returns, and disc login waits forever. |
allow_user_api_key_scopes |
include read (and write for drafts) |
Bounds which scopes a key may request. disc requests read, or read,write with --enable-drafts. |
user_api_key_allowed_groups |
include the issuing user's group | Only members of these groups can generate a User API Key at all. |
About the redirect allowlist. Entries are |-separated and matched with
Discourse's WildcardUrlChecker, so http://127.0.0.1:* accepts the ephemeral
port the CLI binds. This is safe: 127.0.0.1 is loopback — it can only ever
point back at the authorizing user's own machine, never at a remote attacker, and
the returned key is RSA-encrypted with a public key the CLI generated locally
(only the CLI's private key can decrypt it). RFC 8252 endorses exactly this
native-app loopback pattern.
If you prefer not to allow a wildcard port, pin the port instead and allow a single exact URL:
disc login --port 8765 # then allow-list exactly:
# allowed_user_api_auth_redirects -> http://127.0.0.1:8765/Do not broaden the allowlist to non-loopback hosts (e.g. http://*,
https://*.example.com) — those would be genuine open redirects that leak the
key payload to remote servers.
- Trim requestable scopes to least privilege.
allow_user_api_key_scopesdefaults to a mobile-app-oriented set (read|write|message_bus|push|notifications|session_info|one_time_password). Ifdiscis your only client, narrowing it toread|write(or justread) reduces what any key — including one a user might be tricked into authorizing — can request. If you keep the broader set, note thatone_time_passwordis the sharpest scope (it can bootstrap a logged-in web session); drop it unless the official Discourse mobile app needs it on this instance. - Restrict who can mint keys. Keep
user_api_key_allowed_groupslimited to a trusted group (e.g. a specific staff/dev group or a trust level). This shrinks the "socially-engineered authorization" surface more than scope trimming does. - Expire idle keys sooner.
revoke user api keys unused daysdefaults to 180; 30 (or 7–14) is a reasonable tightening. Only idle keys are culled — an actively used key stays valid — anddiscfails gracefully with exit code 4 ("auth required") so the user just re-runsdisc login.
max_draft_length— the composerdatapayload must fit within this.max_drafts_per_user— exceeding it makesPOST /drafts.jsonreturn 403 (surfaced bydiscas exit code 8).
disc context add myforum --host forum.example.com
disc login # authorize in the browser; should return to the CLI
disc whoami # confirms the stored credential resolves to your user
disc categories # confirms read scope + postable categoriesIf disc login hangs after you authorize and the browser shows "Oops! That
page is private." / Forbidden, the allowed_user_api_auth_redirects entry above
is missing or does not match — fix it and re-run.
Confirmed against a live Discourse forum: the User API Key browser flow
(RSA keypair → authorize → PKCS#1 payload decrypt → nonce check); the loopback
auth_redirect requirement (a missing allowed_user_api_auth_redirects entry
produces the 403 described above); the read commands; and the draft path — the
write scope permits POST /drafts.json, the draft persists, and the pre-filled
composer handoff loads title/body/category/tags for the human to publish.
Finding: a bare /new-topic does not auto-restore a server-side draft on
current Discourse, so the handoff uses pre-filled composer URL params instead
(the server draft is still saved). See docs/plan.md §15.
Still baseline / confirm before relying on: the precise meaning of a null
category permission, and reply-draft restoration for --reply-to.
dotnet build src/Discourse.Cli.slnx
dotnet test src/Discourse.Cli.slnxRepository layout:
src/
Discourse.Cli.slnx # solution
disc/ # the tool project (packs as 'Discourse.Cli', command 'disc')
disc.Tests/ # xUnit suite
docs/plan.md # design specification
.github/workflows/ # CI (build/test/pack) and Publish (NuGet on release)
The project packs as a .NET tool (PackAsTool, command disc). Publishing to
NuGet.org is automated by .github/workflows/publish.yml
using Trusted Publishing (OIDC)
— no long-lived API key is stored. NuGet.org introduced this in September 2025 as
the recommended alternative to API keys: the workflow mints a short-lived GitHub
OIDC token, the NuGet/login action exchanges it for a temporary (~1 hour) key,
and that key is used to push.
One-time setup:
- On nuget.org, go to Account → Trusted Publishing and create a policy:
- Package owner/username: your nuget.org username
- Repository owner:
rkttu· Repository:disc - Workflow file:
publish.yml - Environment:
production— the publish job runs in a GitHub environment namedproduction, so the policy's Environment must match (or clear both).
- Set the
user:input inpublish.ymlto that same nuget.org username. - Create a GitHub Release with a tag like
v0.1.0. The workflow derives the package version from the tag, packs, obtains a temporary key via OIDC, and pushes. (The first successful push claims theDiscourse.Clipackage id.)
Trusted Publishing is preferred over long-lived API keys. If you must publish manually, you can still
dotnet nuget pushwith a personal API key, but rotate it and avoid storing it in CI.
Licensed under the Apache License 2.0. Copyright © 2026 rkttu.
If this tool is useful to you, consider sponsoring the author: github.com/sponsors/rkttu.