A recipe is a named, ordered, flag-aware list of steps, with its own
docs. Recipes live in groups: one .exs file holding one or more
recipes, keyed by name.
# recipes/phx.exs
[
clean: [
shortdoc: "Strip the Phoenix theme toggle and topbar",
steps: ["core:phx/remove_theme_toggle", "core:phx/remove_topbar"]
],
app: [
shortdoc: "A Phoenix app with my usual tooling",
doc: """
Builds on phx/clean. Pass --oban-pro for Oban Pro, --gigalixir to
deploy there.
""",
flags: [
oban_pro: "Add Oban Pro",
gigalixir: "Deploy to Gigalixir"
],
steps: [
{:use, "clean"},
"core:setup/credo",
"core:setup/quokka",
"hex:oban",
{"core:oban_pro", if: :oban_pro},
{:use, "local:deploy/gigalixir", if: :gigalixir},
{:task, "ash.codegen", ["initial"]},
"core:setup/sort_deps"
]
]
]The terms:
- A recipe has a name, a shortdoc, an optional doc, optional flags and a list of steps.
- A group is one file under
recipes/. The file name is the group name, so the file above holdsphx/cleanandphx/app. - A cookbook is a package that ships steps (with their templates) and recipe groups.
- Local is just your recipes: recipe groups, nothing else.
Addressing
Every recipe lives in a group and is addressed as
<cookbook>:<group>/<name>:
recipes/<group>.exs → <cookbook>:<group>/<name>so recipes/phx.exs in your local recipes holds local:phx/clean and
local:phx/app. Give groups logical names (ash.exs, lv.exs) rather
than a catch-all recipes.exs. Moving a recipe to another group changes
its address, the same as moving a step.
Recipe keys
| Key | Meaning |
|---|---|
shortdoc (required) | one line, shown by mix composer.recipe.list |
doc | longer help, shown by mix composer.recipe.show |
flags | [flag: "description"], shown by mix composer.recipe.show |
aliases | [lab: "composer_lab"], short cookbook names (see below) |
steps (required) | the entries below |
Every if: :flag in a recipe's own steps must be declared in its own
flags; a flag declared only by a recipe it :uses does not count.
Where groups live
- In a cookbook, at
lib/composer/<package>/recipes/<group>.exs. Your app's ownapp/defaultrecipe, written bymix composer.new, is one. - In your local recipes, at
~/.config/composer/recipes/<group>.exs, outside any project. Point composer elsewhere with-c <dir>or theCOMPOSER_CONFIGenvironment variable (seeComposer.Local). Local holds recipe groups only: no steps, no templates. Personal steps or templates belong in a small cookbook package added as a path dependency.
A group has the same relative path, file format and addressing rule
wherever it lives. Groups are files: a subdirectory under recipes/ is
not a group. Add a recipe to a group (creating the file if needed) with:
mix composer.gen.recipe deploy/gigalixir --local
mix composer.gen.recipe ash/base # in this project's cookbook
Group files are Elixir code, evaluated with
Code.eval_file/1. Only use recipes you wrote or trust.
A group file must end in a literal keyword list ([name: [...], ...])
so its recipe names can be listed, and completed, without evaluating it.
Bindings before the list are fine, and the values can be any expression.
Entries
| Entry | Meaning |
|---|---|
"core:setup/credo" | a step, by "cookbook:path" |
"setup/credo" | a step, unqualified (see below) |
"hex:oban" | a Hex package: added to mix.exs, then its own installer runs |
"hex:org.pkg" | a package from a private Hex repo or organization |
"hex:ash@3.5", "hex:x@path:../x" | a package from a version or source (see Hex packages) |
{"hex:ash_authentication", argv: ["--auth-strategy", "password"]} | a package whose installer runs with arguments |
{"hex:mix_test_watch", only: [:dev, :test], runtime: false} | a package with dependency options |
{"hex:my_lib", path: "../my_lib"} | a package from a source: version:, github:, git: or path: |
{"core:oban_pro", if: :oban_pro} | a step that runs only with --oban-pro |
{"composer_lab:x", argv: ["--all"]} | a step run with options, as after -- on composer.step.run |
{:use, "clean"} | the recipe clean in the same group, in place |
{:use, "local:tools/dev"} | a recipe in another group or cookbook |
{:use, "deploy/gigalixir", if: :gigalixir} | ... only with --gigalixir |
{:task, "x.y", ["arg"]} | compose any Igniter-aware Mix task |
{:queue, "x.y", ["arg"]} | run a Mix task after the changes apply |
A step's argv: and if: combine: {"composer_lab:x", argv: ["--all"], if: :x}.
On a hex: entry, argv: goes to the package's installer (mix <pkg>.install), which always runs with --yes added. A package that
ships no installer has nowhere to send argv:, so the run warns.
{:task, ...} and {:queue, ...} take an if: keyword as a fourth
element. Queued tasks run non-interactively with --yes appended; use them
only for work that must see the applied project on disk.
Hex packages
"hex:pkg" adds a package to mix.exs and runs its own Igniter
installer, if it ships one. Every package in a run is added first and
fetched and compiled once, then each installer runs at its position in
the step list, so its changes land in the run's single diff. The prefix
stays hex: whatever the source: it means "a dependency whose own
installer runs", and Hex is the default source.
The keyword form is the one to use in recipes, because it is validated option by option:
steps: [
"hex:ash",
{"hex:ash", version: "~> 3.5"},
{"hex:ash_authentication", argv: ["--auth-strategy", "password"]},
{"hex:mix_test_watch", only: [:dev, :test], runtime: false},
{"hex:live_debugger", github: "software-mansion/live-debugger", only: :dev},
{"hex:x", git: "https://example.com/x.git", tag: "v1.0.0"},
{"hex:my_lib", path: "../my_lib"}
]| Option | Meaning |
|---|---|
argv: ["--opt", "v"] | arguments for the package's installer |
only: :dev / [:dev, :test] | the dependency's only: |
runtime: false | the dependency's runtime: |
version: "~> 3.5" | a version requirement instead of the latest Hex release |
github: "owner/repo" | a GitHub dependency |
git: url | a git dependency |
path: "../my_lib" | a local dependency, written with override: true |
ref: / branch: / tag: | with github: or git:, at most one of them |
sparse: "dir", submodules: true | with github: or git: |
An entry takes at most one source. Without one, the latest Hex version is
looked up. A private-repo package (hex:org.pkg) takes version: only.
If the package is already in mix.exs, its declaration is replaced, as
mix igniter.install does.
Set only: yourself when you want it: Composer adds the dependency
itself, so an installer's own preference (live_debugger declares
only: [:dev]) is not applied, and tidewave declares none.
The @ shorthand
For the command line, and for people used to mix igniter.install, a
hex: reference can carry its source after an @:
| Reference | Same as |
|---|---|
hex:ash@3.5.0 | version: "== 3.5.0" |
hex:ash@3.5 | version: "~> 3.0" (Igniter's rule) |
hex:ash@~>3.5 | version: "~>3.5", kept as written |
hex:ash@github:ash-project/ash | github: "ash-project/ash" |
hex:ash@github:ash-project/ash@main | ... plus ref: "main" |
hex:x@git:git@github.com:a/x.git | git: "git@github.com:a/x.git" |
hex:my_lib@path:../my_lib | path: "../my_lib" |
The reference is split on its first @, so the source can hold :, @
and -; only the package name is normalized (live-debugger is
live_debugger). An @ source and a keyword source on one entry are an
error, and so is an @ on anything but hex:.
mix composer.step.run hex:my_lib@path:../my_lib
mix composer.step.run hex:ash_authentication -- --auth-strategy password
On the command line, arguments after -- go to the installer. References
are split on ,, so a source containing a comma needs a recipe entry.
Duplicates and conflicts
The same package with the same options, reached through any number of
includes, runs once (only: :dev and only: [:dev] are the same). The
same package with different options — argv, source or dependency
options, or options in one place and none in another — is an error naming
every recipe involved, even when the entries sit behind different if:
flags: both flags can be on at once, and a run adds one dependency per
package. Give each entry the same options, or keep one.
requires: ["hex:pkg"] in a step matches an install of pkg by name,
whatever its source or options. Plan output shows each install's source
and argv:
Composer plan
hex:my_lib (path ../my_lib) — dependency only, ships no installer
hex:ash_authentication --auth-strategy password — ash_authentication.install
When to write a step instead
Write a step only when the package ships no installer, or when you
need more than its installer does. A step that only adds a package
and runs its installer duplicates hex: and costs a deps fetch and
compile of its own; use a hex: entry with argv:, dependency options or
a source instead. To patch what an installer wrote, list your step after
the hex: entry.
Resolving references
"cookbook:path" is exact. An unqualified step "path":
- is looked up first in the recipe's own cookbook (on the command line, the current project's cookbook);
- then in every cookbook. One match resolves to it; more than one is an error listing every candidate, so qualify the reference;
- matching nothing, is the Hex package of that name —
"oban"is"hex:oban"— unless it is very close to a step id, which is reported as a likely typo.
A step in a cookbook wins over a Hex package of the same name.
{:use, "name"} without a / is the recipe of that name in the same
group; nothing outside the group can match it. {:use, "group/name"}
resolves like an unqualified step (own cookbook first, then every cookbook
including local), with no Hex fallback.
Cookbook aliases
Cookbook names equal their package's OTP app, so qualified references can
be long: composer_lab:auth/passkeys. A recipe can declare short names
for the cookbooks it refers to:
passkeys: [
shortdoc: "Passkey sign-in",
aliases: [lab: "composer_lab"],
steps: ["lab:auth/passkeys", {:use, "lab:auth/base"}]
]lab:auth/passkeys then resolves exactly as composer_lab:auth/passkeys.
An alias is only a lookup shortcut; it never renames a cookbook:
- it applies to the declaring recipe's own step and
{:use, ...}references only — not inside a recipe it includes (that recipe uses its own aliases), and never to references typed on the command line; - its value is a string naming a package cookbook.
core,hex,local(and other reserved names) cannot be targets, and an atom value is rejected; - its name is a lowercase
[a-z0-9_]name other thancore,hexandlocal, declared once per recipe, and it must not be the name of an installed cookbook — pick alias names that don't look like package names, since a cookbook installed later would collide; - listings,
--jsonand run commits show the real cookbook name;recipe.showmarks an aliased step with(as lab), and errors quote the reference as written:lab:auth/passkeys (lab = composer_lab): cookbook composer_lab not found.
To share aliases across a group, bind them before the final list (the file must still end in a literal keyword list):
# recipes/auth.exs
aliases = [lab: "composer_lab"]
[
passkeys: [shortdoc: "Passkey sign-in", aliases: aliases, steps: ["lab:auth/passkeys"]],
magic: [shortdoc: "Magic-link sign-in", aliases: aliases, steps: ["lab:auth/magic_link"]]
]Composition rules
- Include-once. A recipe is expanded at most once per run; later includes of it are no-ops. A diamond is not a cycle.
- No cycles. A cycle is an error that prints the whole chain, across
groups and cookbooks:
local:ash/a -> composer_lab:lv/b -> local:ash/a. - De-duplication. A step equal to an earlier one is dropped, first
occurrence wins —
"setup/credo"and"core:setup/credo"count as the same step. Steps that differ only in theirif:are distinct. Keep finishing steps like"core:setup/sort_deps"in a recipe that is used last. - Flags declared by the recipe, and by every recipe it reaches,
become
--flagoptions ofmix composer.recipe.run. Any other--flagis rejected.
Listing, showing and running
mix composer.recipe.list # every cookbook, local first
mix composer.recipe.list local # one cookbook
mix composer.recipe.list local composer_lab # several (separate arguments)
mix composer.recipe.list local:phx/ # one group (note the slash)
mix composer.recipe.show local:phx/app # docs, flags, resolved steps
mix composer.recipe.edit local:phx/app # open its group file in your editor
mix composer.recipe.run local:phx/app --oban-pro
mix composer.recipe.run local:phx/app --dry-run
recipe.show follows every gated include, so it shows everything the
recipe can run, and warns about phase order, overlapping provides,
conflicts, and required steps that aren't listed earlier.
recipe.run resolves every reference and checks every step's requires
before anything runs, applies the steps as one diff without asking, and
records the run as one git commit listing every step and the recipe it
came from. It refuses to start outside a git work tree or in a dirty one;
--no-commit skips the git checks and the commit.
recipe.edit opens the recipe's group file at the recipe's line, with
the command in COMPOSER_EDITOR (then VISUAL, then EDITOR).
__FILE__ and __LINE__ in it are replaced, as in ELIXIR_EDITOR:
export COMPOSER_EDITOR="nvim +__LINE__ __FILE__" # this terminal
export COMPOSER_EDITOR="ghostty -e nvim -c __LINE__ __FILE__" # a new window
Pass --print to print path:line instead.
Checking recipes
mix composer.cookbook.lint --local # your local recipes
mix composer.cookbook.lint # this project's cookbook, recipes included
mix composer.cookbook.lint --format compact --file ~/.config/composer/recipes/ash.exs
To see problems in your editor as you save a group file, see the lint guide.
Lint reports a stray entry in ~/.config/composer/ (anything but
recipes/; steps/ and templates/ with a note that local holds
recipes only), a subdirectory under recipes/, a group file that is not
a keyword list of recipes, a duplicate recipe name, a missing shortdoc
or steps, an unknown key, an undeclared if: flag, an invalid,
reserved, duplicate or shadowing alias, a recipe named like a group, and
every recipe that does not resolve. It warns about an alias that none of
the recipe's references use, and about an alias that names different
cookbooks in two recipes of one group.
Starting a new app from a recipe
mix composer.new --from local:phx/app
copies the recipe into the new app as app/default, so the app owns and
can edit its setup while your recipe stays the template. Same-group
{:use, ...} entries are qualified so they still resolve; comments are not
copied.
Migrating from one recipe per file
Recipes used to be one file each, with help text in a leading #
comment. That format is gone, and loading such a file is an error pointing
at the group format. Convert by hand: gather related files into one group,
key each recipe by its old name, move the comment into shortdoc:, and
point {:use, "local:old_name"} at the new address (or at {:use, "name"} within the same group).
# before: recipes/ash_base.exs and recipes/ash_ecto.exs
# Ash with the Postgres data layer.
[{:use, "local:ash_base"}, "composer_lab:ash/postgres"]
# after: recipes/ash.exs
[
base: [shortdoc: "Ash core", steps: ["composer_lab:ash"]],
ecto: [
shortdoc: "Ash with the Postgres data layer",
steps: [{:use, "base"}, "composer_lab:ash/postgres"]
]
]