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
endTo use it, list the module under plugins: in marea.yaml:
plugins:
- MyOrg.Plugins.SentryThe 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/1for common reusable specs.- Tag any option/flag with
global: trueto have it injected into every leaf subcommand byArgs.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:
:contto let the next plugin try.:ok/{:ok, config}for "we handled it; continue normally".:usageto print Optimus help forcmds.{: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: :contmarea_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:
| Field | Notes |
|---|---|
marea_config | The validated marea.yaml map. Your fields appear here. |
cmds | Parsed subcommand path (e.g. [:build, :docker]). |
options | Parsed options (e.g. %{deploy: "staging", release: "api"}). |
flags | Parsed flags. |
values | Persisted last-values plus things plugins added during this run (:git_vsn, :image, …). |
configs | Files under <marea_dir>/configs/, parsed (YAML) or raw. |
secrets | Files under <marea_dir>/secrets/, same idea. |
marea_dir | Resolved value of marea_dir. |
state_dir | Resolved 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 inlast_values).cmd!/3,pause_cmd!/3,result!/3,shell/3— shell-out helpers that respect thecmd_prefix:config.get_maybe_secret/2— resolvessecret!file!keyreferences.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:contso the chain reaches the right handler. - Avoid
System.halt/1in plugins. Use{:error, msg}orLib.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 raisesMarea.Stop(set by the Runner viaMarea.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 callLib.stop/1; pick the right host and the right behaviour follows. - Mind interactive prompts.
IO.getsandLib.confirm?/1block the MCP server's stdio channel.Lib.pause_cmd!/3already short-circuits on--nopause, which the MCP runner always injects. If your plugin prompts elsewhere, gate it onflags[:nopause]or skip it when the prompt is not essential. - Persist anything you want next time. Anything you put in
config.valuesviaLib.add_values/2is written to<state_dir>/last_valuesautomatically 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()andArgs.release_option()— Marea will fall back to last-values, andLib.get_release!/1gives 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/2is 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_pluginsinlib/marea.ex; otherwise leave it out and users opt in viaplugins:(this is whatMarea.Plugins.Docker/Helm/docker.python/Aws/K8salready 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:inmarea.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.