Marea's CLI is a stack of plugins. Adding a new subcommand, validating a new field in marea.yaml, or hooking into setup init-release is done by writing a plugin module and listing it under plugins: in the project's marea.yaml.

This guide assumes you've read 05-architecture.md for the wider picture.

Anatomy of a plugin

Every plugin is a regular Elixir module:

defmodule MyOrg.Plugins.Sentry do
  use Malla.Plugin, plugin_deps: [Marea.Plugins.Base]

  alias Marea.{Lib, Config}
  alias Marea.Config.Args

  ## ===================================================================
  ## Schema
  ## ===================================================================

  defcb marea_config_schema(base) do
    sentry =
      Zoi.map(
        %{
          dsn: Zoi.string() |> Zoi.optional(),
          environment: Zoi.string() |> Zoi.optional()
        },
        coerce: true
      )
      |> Zoi.optional()

    {:cont, [Marea.Config.Schema.add_field_at_path(base, [], :sentry, sentry)]}
  end

  ## ===================================================================
  ## CLI args
  ## ===================================================================

  defcb marea_config_args(args) do
    subcommands = [
      sentry: [
        name: "sentry",
        about: "Sentry-related operations",
        subcommands: [
          release: [
            name: "release",
            about: "Notify Sentry of a new release",
            options: [Args.deploy_option(), Args.release_option()]
          ]
        ]
      ]
    ]

    {:cont, [Args.add_subcommands(args, subcommands)]}
  end

  ## ===================================================================
  ## Command dispatch
  ## ===================================================================

  defcb marea_cmd([:sentry | rest], %Config{} = config), do: sentry_cmd(rest, config)
  defcb marea_cmd(_cmds, _config), do: :cont

  ## ===================================================================
  ## Commands
  ## ===================================================================

  defp sentry_cmd([:release], config) do
    {_deploy, _deploy_data, release, _release_data} = Lib.get_release!(config)
    %{git_vsn: vsn} = config.values

    case config.marea_config[:sentry] do
      %{dsn: dsn, environment: env} ->
        Lib.cmd!("sentry-cli releases new #{release}@#{vsn} --org acme --project #{env}")
        Lib.cmd!("sentry-cli releases finalize #{release}@#{vsn}")
        :ok

      _ ->
        {:error, "sentry not configured in marea.yaml"}
    end
  end

  defp sentry_cmd(_, _config), do: :usage
end

To use it, list the module under plugins: in marea.yaml:

plugins:
  - MyOrg.Plugins.Sentry

The plugin is loaded for every Marea invocation and its CLI subcommand appears under marea --help and as an MCP tool when running marea mcp serve. No extra work is required: Marea.Mcp.Tools walks the same Marea.Plugins.Base.marea_config_args/1 tree the CLI is built from, so the moment a plugin contributes a leaf subcommand it also contributes a typed MCP tool. See the MCP Plugin guide for the Optimus → JSON Schema mapping and the per-tool annotations (destructiveHint, readOnlyHint) you can influence.

The callbacks

All callbacks are declared in Marea.Plugins.Base and dispatched via Malla's compile-time chain. The conventional return values are :cont, {:cont, args}, or a final value.

marea_config_schema(schema)

Enrich the validated marea.yaml schema. Always return {:cont, [new_schema]}.

Use Marea.Config.Schema.add_field_at_path/4 to inject fields. The path is a list of atom keys; an empty path adds at the root. Traverse descends into dynamic-keyed maps automatically, so:

add_field_at_path(schema, [:deploys, :releases], :my_field, Zoi.string())

adds :my_field to every release entry without you having to walk the map manually.

marea_config_args(args)

Enrich the CLI tree. Use the helpers in Marea.Config.Args:

  • add_subcommands/2, add_options/2, add_flags/2.
  • deploy_option/0, release_option/0, cookie_option/0, pos_option/0, mix_env_option/1 for common reusable specs.
  • Tag any option/flag with global: true to have it injected into every leaf subcommand by Args.build_optimus/1.

The internal format is the one Optimus expects.

marea_cmd(cmds, config)

Handle a parsed command. cmds is a list of atoms — the subcommand path the user typed. Return one of:

  • :cont to let the next plugin try.
  • :ok / {:ok, config} for "we handled it; continue normally".
  • :usage to print Optimus help for cmds.
  • {:cmd, cmd} / {:cmd, cmd, config} to defer a shell command.
  • {:error, msg} to abort.

The conventional pattern is to match your top-level subcommand atom and fall through to :cont for everything else:

defcb marea_cmd([:my_cmd | rest], %Config{} = config), do: my_cmd(rest, config)
defcb marea_cmd(_cmds, _config), do: :cont

marea_release_vsn()

Override the release version string. The default (Marea.Plugins.Build) returns <date>-<git-sha>. Return {:ok, "vsn-string"} to short-circuit, or :cont to let the chain continue.

marea_init_release_file(name)

Provide content for files written by marea setup init-release. Marea.Plugins.Setup calls this with :env_sh_eex to fetch the contents of rel/env.sh.eex. Override in a plugin to inject a custom template; return :cont to accept the default.

marea_deploy_types(types) / marea_release_types(types)

Contribute values to the deploys.<d>.type: and deploys.<d>.releases.<r>.type: enums respectively. Each plugin appends its own atoms to the accumulating list:

defcb marea_deploy_types(types), do: {:cont, [types ++ [:helm]]}

Marea.Plugins.Helm contributes :helm; Marea.Plugins.Docker contributes :elixir and :dockerfile_dir. The first contributed value becomes the default for the field. With no plugin contributing, the corresponding type: field is omitted from the schema entirely.

marea_dockerfile_assigns(assigns, config)

Hook for plugins to influence the Dockerfile rendered by Marea.Plugins.Docker. Declared by the Docker plugin itself (Marea.Plugins.Docker.marea_dockerfile_assigns/2), so any plugin implementing it must list Marea.Plugins.Docker in plugin_deps:. Receives the EEx assigns (:elixir_vsn, :rebuild_deps, :path_deps, plus the extension points :extra_stages and :extra_app_steps) and the active config. docker.python is the canonical example: when a release sets docker.python:, it appends a python-builder stage to :extra_stages and the venv-copy / apk add python3 lines to :extra_app_steps.

Working with Marea.Config.t/0

The struct passed to Marea.Plugins.Base.marea_cmd/2 carries everything the plugin needs:

FieldNotes
marea_configThe validated marea.yaml map. Your fields appear here.
cmdsParsed subcommand path (e.g. [:build, :docker]).
optionsParsed options (e.g. %{deploy: "staging", release: "api"}).
flagsParsed flags.
valuesPersisted last-values plus things plugins added during this run (:git_vsn, :image, …).
configsFiles under <marea_dir>/configs/, parsed (YAML) or raw.
secretsFiles under <marea_dir>/secrets/, same idea.
marea_dirResolved value of marea_dir.
state_dirResolved value of state_dir.

Common helpers in Marea.Lib:

  • get_deploy!/1{deploy, deploy_data} or aborts.
  • get_release!/1{deploy, deploy_data, release, release_data} or aborts.
  • add_values/2 — merge new entries into :values (they'll be persisted in last_values).
  • cmd!/3, pause_cmd!/3, result!/3, shell/3 — shell-out helpers that respect the cmd_prefix: config.
  • get_maybe_secret/2 — resolves secret!file!key references.
  • print_table/2 — pretty-prints a header table before destructive operations.

Tips

  • Pass through with :cont. Plugins that don't match the current command must return :cont so the chain reaches the right handler.
  • Avoid System.halt/1 in plugins. Use {:error, msg} or Lib.stop/1. The latter is mode-aware: in the escript path it prints in red and exits with code 126; under the MCP server it raises Marea.Stop (set by the Runner via Marea.Lib.set_stop_mode/1) so a single bad tool call doesn't bring the long-running server down. As a plugin author you just call Lib.stop/1; pick the right host and the right behaviour follows.
  • Mind interactive prompts. IO.gets and Lib.confirm?/1 block the MCP server's stdio channel. Lib.pause_cmd!/3 already short-circuits on --nopause, which the MCP runner always injects. If your plugin prompts elsewhere, gate it on flags[:nopause] or skip it when the prompt is not essential.
  • Persist anything you want next time. Anything you put in config.values via Lib.add_values/2 is written to <state_dir>/last_values automatically and becomes a default for the next run.
  • Reuse the deploy/release plumbing. If your command logically works on a release, take Args.deploy_option() and Args.release_option() — Marea will fall back to last-values, and Lib.get_release!/1 gives you a clean {deploy, deploy_data, release, release_data} tuple.
  • Keep IO loud. Marea is a CLI; print what it's doing and what command it ran. Lib.print_table/2 is good for "about to do X with these values" summaries.

Bundling vs. enabling plugins

Two ways to ship a plugin:

  • In a Marea fork — add the file under lib/marea/plugins/. If the plugin should always be loaded, register it in @base_plugins in lib/marea.ex; otherwise leave it out and users opt in via plugins: (this is what Marea.Plugins.Docker / Helm / docker.python / Aws / K8s already do — they're shipped in the same source tree but not part of @base_plugins).
  • As a regular dep — publish it as its own package. Users add the module name to plugins: in marea.yaml. This is the recommended path for organisation- or team-specific behaviour.

For day-to-day work (custom commands per project), the second option is almost always the right one.

Cross-plugin dependencies

use Malla.Plugin, plugin_deps: [Marea.Plugins.Docker, Marea.Plugins.Base] declares that your plugin requires Marea.Plugins.Docker to be loaded. Malla resolves this transitively, so listing your plugin in marea.yaml automatically pulls Docker in (and Docker's Build dependency, and so on). This is how Marea.Plugins.Helm ensures the container-build pipeline is available when it implements build helm, and how docker.python ensures the Dockerfile assigns chain it hooks into is actually invoked.