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.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
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)
classDiscourseCommandCenter::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::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] # 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.
classDiscourseCommandCenter::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) # AUDITDiscourseCommandCenter::Result.success(
message:I18n.t("command_center.plans.create_category", name: category.name),
data: { url:"/c/#{category.slug}/#{category.id}" },
)
elseDiscourseCommandCenter::Result.error(category.errors.full_messages)
endendend
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-sidedirective: { 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 be register_svg_icon'd in plugin.rb).
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.