Authoring command-center commands
The command center (plugins/discourse-command-center/) is the admin cmd+/
palette. A command is declared once and from that single declaration the
framework derives: the client-side trigger matcher, the plan preview, the
plan-mode form (one editable field per param), the audited execution, and a
future LLM tool schema.
Prefer wrapping an existing Service::Base with the service macro — it
imports the service's contract (param names, types, required-ness, enums) and
calls it for you. Drop to an inline execute block only when there is no
service.
Where commands live
- One class per command in
plugins/discourse-command-center/lib/discourse_command_center/commands/*.rb. - Subclass
DiscourseCommandCenter::Command. Classes self-register (viaCommand.inherited) and are loaded byplugin.rb'safter_initialize, which constantizes every file in that directory. Just add the file — no manual registration. - Base class:
lib/discourse_command_center/command.rb. Param types:lib/discourse_command_center/param_types.rb. Don't read other plugins for patterns; read those two files and the examples below.
Reference examples (read these first)
- Service-wrapped:
commands/suspend_user.rb,commands/silence_user.rb,commands/create_group.rb. - Inline execute:
commands/create_category.rb. - Directive (can't run synchronously):
commands/grant_admin.rb,commands/impersonate.rb.
The service macro (preferred path)
class DiscourseCommandCenter::Commands::SuspendUser < DiscourseCommandCenter::Command
identifier :suspend_user
title "command_center.commands.suspend_user.title"
description "command_center.commands.suspend_user.description"
icon "ban"
triggers "suspend", "ban"
# Import the service's contract as command params. Only these attributes,
# and rename user_id -> user so we can resolve a real User.
service ::User::Suspend, only: %i[user_id reason suspend_until], map: { user_id: :user }
# Re-type imported params where the command wants richer input than the raw
# contract attribute (a username, a relative duration). Re-declaring a param
# merges onto the imported one.
param :user, :user
param :suspend_until, :duration, labels: %w[until for]
param :reason, :string, labels: %w[reason because]
guardian { |g, r| g.can_suspend?(r[:user]) }
plan { |r| I18n.t("command_center.plans.suspend_user", user: r[:user]&.username || "?", until: ...) }
end
What service Klass, only:, except:, map: does:
- Reads
Klass::Contractand imports each attribute as aparam, deriving:- type from the contract's
attribute_types(:integer→:integer,:datetime→:datetime, …), - required from the contract's presence validators,
- enum from the contract's inclusion validators.
- type from the contract's
only:/except:filter which contract attributes become params.map: { contract_attr => command_param }renames a param and recordsmaps_toso execution sends the right key back to the service.- Default
execute(when noexecuteblock is given): rebuilds the service params from the resolved values — a resolved record is sent as itsid(resolved[:user]→user_id) — callsKlass.call(params:, guardian:), and maps a failed service context to a proper error (contract messages, status).
Re-typing imported params: the contract gives you user_id:integer, but the
palette should accept a username and resolve a User. Re-declare
param :user, :user (after the service line) — re-declaring merges type/labels
onto the imported param while keeping maps_to: :user_id. Same for turning a
:datetime into a natural-language :duration.
result_link (service commands): make the success message clickable.
result_link do |resolved:, context:, **|
group = context[:group] # the service's context (created record, etc.)
{ url: "/g/#{group.name}", label: group.name } if group
end
Inline execute (no service)
When there's no Service::Base, declare params yourself and provide execute.
You are responsible for the side effect, validation, audit logging, and the
return Result.
class DiscourseCommandCenter::Commands::CreateCategory < DiscourseCommandCenter::Command
identifier :create_category
title "command_center.commands.create_category.title"
icon "folder-plus"
triggers "create category", "new category" # multiword triggers OK
param :name, :string, required: true
guardian { |g, _r| g.can_create?(::Category) }
plan { |r| I18n.t("command_center.plans.create_category", name: r[:name]) }
execute do |resolved:, guardian:|
category = ::Category.new(name: resolved[:name], user: guardian.user)
if category.save
::StaffActionLogger.new(guardian.user).log_category_creation(category) # AUDIT
DiscourseCommandCenter::Result.success(
message: I18n.t("command_center.plans.create_category", name: category.name),
data: { url: "/c/#{category.slug}/#{category.id}" },
)
else
DiscourseCommandCenter::Result.error(category.errors.full_messages)
end
end
end
Result (lib/discourse_command_center/result.rb):
Result.success(message:, data: {}, directive: nil)Result.error(messages, status: :unprocessable_entity)(:conflict,:forbidden,:not_found, … map to HTTP codes)
Always audit inline commands the way the equivalent core controller does
(StaffActionLogger / GroupActionLogger). Service-wrapped commands inherit
the service's own logging for free.
Actions that can't run in one request → directive
Some flows mutate the session or need 2FA/email confirmation and cannot complete in a JSON call. Return a directive the client performs instead of mutating:
# grant_admin: 2FA/email-gated → hand off to the existing admin user page
execute do |resolved:, guardian:|
user = resolved[:user]
DiscourseCommandCenter::Result.success(
message: I18n.t("command_center.plans.grant_admin", user: user.username),
directive: { type: "route", url: "/admin/users/#{user.id}/#{user.username}" },
)
end
# impersonate: sets the session cookie client-side
directive: { type: "ajax_then_redirect", method: "POST", url: "/admin/impersonate",
data: { username_or_email: user.username }, redirect: "/" }
Directive types handled by the plan card: route (DiscourseURL.routeTo) and
ajax_then_redirect.
Param types
Declared as param :name, :type, required:, labels:, enum:, maps_to:, default:.
labels: are words that bind the next token to this param (until 3 weeks).
| type | input the admin types | resolves to | plan-mode control |
|---|---|---|---|
:user |
username / email (@ ok) |
User (ambiguous → chooser) |
user chooser |
:category |
name / slug | Category |
category chooser |
:group |
name (@ ok) |
Group |
group chooser |
:tag |
#tag / name |
Tag |
tag input |
:duration |
3 weeks, 2d, tomorrow, forever, ISO |
Time |
calendar |
:datetime |
ISO / YYYY-MM-DD (falls back to duration) |
Time |
calendar |
:string |
quoted '…' or free text (filled last) |
string | text |
:integer :boolean :enum :email :domain |
matched by shape / set | scalar | number/toggle/select/text |
Duration/datetime parsing lives in ParamTypes::Duration/Timestamp in
param_types.rb (units in UNIT_SECONDS). Add new types there if needed.
Checks (pre-flight hits)
check surfaces a "hit" in the plan when a condition holds. Two flavors:
# Non-blocking heads-up (Confirm still enabled):
check { |r| I18n.t("...") if some_soft_condition(r) }
# Blocking — disables Confirm AND is enforced server-side at execute (409):
check(blocking: true) do |r|
user = r[:user]
I18n.t("command_center.checks.suspend_user.already_suspended", user: user.username, until: ...) if user&.suspended?
end
Use check(blocking: true) for state preconditions that make the action
impossible (already suspended, name already taken). Use guardian for
permission. Use plain check for advisory notes. A check that raises is
swallowed — it never breaks the plan.
Full DSL reference (Command class methods)
identifier :symbol— unique id (used in/plan,/execute, catalog).title "i18n.key"/description "i18n.key"— server-side i18n keys.icon "name"— FontAwesome name (must beregister_svg_icon'd inplugin.rb).triggers "word", "two words", …— lowercase aliases; multiword matched longest-first.param name, type, required:, labels:, enum:, maps_to:, default:service Klass, only:, except:, map:— import a contract + default execute.guardian { |guardian, resolved| boolean }— hard permission gate.check(blocking:) { |resolved| message_or_nil }— pre-flight hit(s).plan { |resolved| String }— human preview (shown when complete).execute { |resolved:, guardian:| Result }— the side effect (omit whenservicehandles it).result_link { |resolved:, context:, **| { url:, label: } }— clickable success (service commands).
Required i18n + icon for every new command
In config/locales/server.en.yml:
command_center.commands.<id>.titleand.descriptioncommand_center.plans.<id>(preview sentence; use placeholders)command_center.params.<param>for any new param name (the field label)command_center.checks.<id>.<key>for any check messages
In plugin.rb: register_svg_icon "<icon>" for the command's icon.
Strings are "Sentence case", use placeholders (never split strings).
Tests (always add)
- Request spec (
spec/requests/discourse_command_center/commands_controller_spec.rb):/plan(resolved fields + preview + guardian/blocked/hits) and/execute(happy path asserts the side effect and aUserHistoryrow; guardian denial → 403; blocking check → 409; contract failure → 422; directive commands return adirectiveand mutate nothing). - Service-macro / framework (
spec/lib/discourse_command_center/command_spec.rb): assert the imported params, required-ness, and any check behavior. - Parser changes (new param-type recognizers) →
spec/lib/discourse_command_center/parser_spec.rb.
Run: bin/rspec plugins/discourse-command-center/spec/lib plugins/discourse-command-center/spec/requests.
Lint everything with bin/lint --fix (rubocop + prettier + stylelint).
Checklist for a new command
- Create
commands/<name>.rb; subclassCommand; setidentifier,title,description,icon,triggers. - Service-backed?
service Klass, only:, map:then re-type record/duration params. No service? declareparams + anexecutethat does the work, audits, and returns aResult. - Add
guardian. Addcheck(blocking: …)for preconditions. - Add
planpreview and (service)result_link. - Add i18n keys (title/description/plan/params/checks) and
register_svg_icon. - Add request + framework specs.
bin/rspec+bin/lint --fix. - The command auto-appears in the palette, the empty-state browse list, and the admin reference page — no extra wiring.