Discourse.Cli 0.1.2

dotnet tool install --global Discourse.Cli --version 0.1.2
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Discourse.Cli --version 0.1.2
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=Discourse.Cli&version=0.1.2
                    
nuke :add-package Discourse.Cli --version 0.1.2
                    

Discourse CLI (disc)

NuGet Downloads CI License: Apache 2.0 .NET

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 ... [--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.

Draft attribution (mandatory)

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 disc tag.

--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 & 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-drafts requests read,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.

  • Trim requestable scopes to least privilege. allow_user_api_key_scopes defaults to a mobile-app-oriented set (read|write|message_bus|push|notifications|session_info|one_time_password). If disc is your only client, narrowing it to read|write (or just read) reduces what any key — including one a user might be tricked into authorizing — can request. If you keep the broader set, note that one_time_password is 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_groups limited 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 days defaults to 180; 30 (or 7–14) is a reasonable tightening. Only idle keys are culled — an actively used key stays valid — and disc fails gracefully with exit code 4 ("auth required") so the user just re-runs disc login.
  • max_draft_length — the composer data payload must fit within this.
  • max_drafts_per_user — exceeding it makes POST /drafts.json return 403 (surfaced by disc as 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); 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.

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:

  1. 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 named production, so the policy's Environment must match (or clear both).
  2. Set the user: input in publish.yml to that same nuget.org username.
  3. 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 the Discourse.Cli package id.)

Trusted Publishing is preferred over long-lived API keys. If you must publish manually, you can still dotnet nuget push with a personal API key, but rotate it and avoid storing it in CI.

License

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.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.1.2 95 7/5/2026
0.1.1 83 7/5/2026
0.1.0 100 7/5/2026 0.1.0 is deprecated because it is no longer maintained.