Discourse.Cli
0.1.0
See the version list below for details.
dotnet tool install --global Discourse.Cli --version 0.1.0
dotnet new tool-manifest
dotnet tool install --local Discourse.Cli --version 0.1.0
#tool dotnet:?package=Discourse.Cli&version=0.1.0
nuke :add-package Discourse.Cli --version 0.1.0
Discourse CLI (disc)
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).
Install
Once published to NuGet.org:
dotnet tool install --global Discourse.Cli
Or 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.Cli
Quick start
disc 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 categories
To 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.
Commands
| 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 ... |
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.
Authentication & storage
- 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.
Exit codes
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.
Administrator setup guide (forum admin)
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.
Required — the tool will not work without these
| 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.
Recommended — security hardening
- 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.
Draft-related settings (only if using disc draft)
max_draft_length— the composerdatapayload must fit within this.max_drafts_per_user— exceeding it makesPOST /drafts.jsonreturn 403 (surfaced bydiscas exit code 8).
Quick verification
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 categories
If 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.
Verification status
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), and the read commands.
Still baseline / confirm before relying on (see docs/plan.md §15): the
exact data shape for POST /drafts.json, whether a bare /new-topic restores
the draft, and the precise meaning of a null category permission. These are
implemented to the documented baseline and marked in code.
Build & test
dotnet build src/Discourse.Cli.slnx
dotnet test src/Discourse.Cli.slnx
Repository 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)
Publishing (maintainers)
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.
License
Licensed under the Apache License 2.0. Copyright © 2026 rkttu.
Sponsor
If this tool is useful to you, consider sponsoring the author: github.com/sponsors/rkttu.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.