# Discourse Command Center Authoring

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

- Skill: `discourse/discourse-command-center-authoring` (Agent Skill)
- Install (CLI): `npx skillmds@latest add discourse/discourse-command-center-authoring`
- Raw SKILL.md: https://api.skillmd.com/api/skills/discourse/discourse-command-center-authoring/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: discourse (https://skillmd.com/u/discourse)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/discourse/discourse-command-center-authoring

---


# 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)

```ruby
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::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.

```ruby
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`.

```ruby
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:

```ruby
# 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:

```ruby
# 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`).
- `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

1. Create `commands/<name>.rb`; subclass `Command`; set `identifier`, `title`,
   `description`, `icon`, `triggers`.
2. **Service-backed?** `service Klass, only:, map:` then re-type record/duration
   params. **No service?** declare `param`s + an `execute` that does the work,
   audits, and returns a `Result`.
3. Add `guardian`. Add `check(blocking: …)` for preconditions.
4. Add `plan` preview and (service) `result_link`.
5. Add i18n keys (title/description/plan/params/checks) and `register_svg_icon`.
6. Add request + framework specs. `bin/rspec` + `bin/lint --fix`.
7. The command auto-appears in the palette, the empty-state browse list, and the
   admin reference page — no extra wiring.

