| name | discourse-command-center-authoring |
| description | Use when adding or editing a command for the discourse-command-center plugin (the cmd+/ admin command palette) — especially turning an existing Service::Base into a command via the `service` macro, but also inline-execute and directive commands. Covers the command DSL, param types, checks, i18n, icons, and tests. |
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 (via
Command.inherited) and are loaded by plugin.rb's after_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"
service ::User::Suspend, only: %i[user_id reason suspend_until], map: { user_id: :user }
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::Contract and imports each attribute as a param, 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.
only:/except: filter which contract attributes become params.
map: { contract_attr => command_param } renames a param and records
maps_to so execution sends the right key back to the service.
- Default
execute (when no execute block is given): rebuilds the service
params from the resolved values — a resolved record is sent as its id
(resolved[:user] → user_id) — calls Klass.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]
{ 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"
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)
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:
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
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:
check { |r| I18n.t("...") if some_soft_condition(r) }
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 be register_svg_icon'd in plugin.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 when service handles 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>.title and .description
command_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 a UserHistory row; guardian
denial → 403; blocking check → 409; contract failure → 422; directive
commands return a directive and 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; subclass Command; set identifier, title,
description, icon, triggers.
- Service-backed?
service Klass, only:, map: then re-type record/duration
params. No service? declare params + an execute that does the work,
audits, and returns a Result.
- Add
guardian. Add check(blocking: …) for preconditions.
- Add
plan preview 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.