Skip to content

Configuring .drupalaibp/

A config is a *.json file inside a .drupalaibp/ directory in the place you launch the installer from. When one is present, the installer reads it and pre-configures the whole build — project type, coding agents, agent skills, provisioning (Composer packages, modules, recipes, DDEV add-ons), custom questions, and hook scripts fired at named build events.

# in a directory containing .drupalaibp/config.json
bash <(curl -fsSL https://project.pages.drupalcode.org/one_line_installer/drupalaibp)

Upgrading from 1.x? The single root .drupalaibp.json is no longer read. Move it into the directory: mkdir .drupalaibp && git mv .drupalaibp.json .drupalaibp/config.json.

The structural choices (profile, agents, skills, deny rules, provisioning) come from the config and aren't prompted. The config's questions are still asked interactively, each with its default pre-filled, so you can review or adjust them. Add --yolo to take every default silently and run fully unattended.

There are three complete, runnable examples in docs/examples/ — a Drupal site with provisioning, a non-Drupal Node/Storybook project, and a source offering several configs through the picker.

Where a config lives, and picking one

Every *.json directly under .drupalaibp/ is a named config — the file's basename (without .json) is its name. The simplest project has a single .drupalaibp/config.json.

  • One config in .drupalaibp/ is used automatically.
  • Several configs — pass --config <name> (the basename, no .json) to pick one, or choose interactively from the list. In an unattended run (--yolo, or a source loaded with no terminal) a multi-config directory with no --config is a hard error that names the available configs.
  • Configs in other repositories — a .drupalaibp/remote-configs.json pointer file adds configs that live elsewhere to the same list. It is not a config itself; see below.

The same .drupalaibp/ directory can be loaded from several places — the launch directory (auto-detected), a positional source drupalaibp <source>, or the --config-path/--config-url/--global-config flags. See the setup-site reference for the full source list and flag equivalences.

Ordering the picker: weight, label, and description

Three optional top-level keys control how a config appears in that list:

{
  "weight": -10,              // optional; lower sorts first; default 0
  "label": "Minimal",         // optional; shown in the picker; defaults to the file name
  "description": "A bare-bones Drupal site with no extras." // optional; shown as detail text
}
  • The list is sorted by weight ascending (the usual Drupal convention: lower first), then alphabetically by file name as the tie-break.
  • A config that omits weight is treated as 0, so a set that never sets a weight is plain alphabetical, and a weighted config sorts ahead of or behind the unweighted ones by its value relative to 0.
  • label is display-only and should stay short — it's shown as one row of the picker (Label (name) when it differs from the file name) and is truncated if it doesn't fit the terminal width.
  • description is display-only, longer-form text. When at least one config in the set sets it, the picker shows a word-wrapped detail panel below the list holding the highlighted option's description, updating as the cursor moves. Unlike label, description is never truncated — it wraps across as many lines as it needs. A config with no description simply shows no panel text when highlighted.
  • All three are ignored everywhere else: they never affect what gets built, and a single config in .drupalaibp/ still loads straight away with no picker no matter what it sets.

A worked example of all of this — four configs, weighted and unweighted, with the picker they produce — is in docs/examples/multi-config/.

Offering configs from another repository

A .drupalaibp/ can only hold configs that physically live in it. To offer a config that is maintained in another repository — an umbrella or team repo curating a single entry point for configs owned by other projects — add a .drupalaibp/remote-configs.json pointer file:

[
  {
    "repo": "https://git.drupalcode.org/project/some_project", // required
    "config": "minimal",                                       // required
    "ref": "2.0.x",                                            // optional
    "label": "Minimal site",                                   // optional
    "description": "A bare-bones Drupal site with no extras.", // optional
    "weight": -10                                              // optional
  }
]
  • repo is a git URL, or the owner/repo drupal.org shorthand (project/some_projecthttps://git.drupalcode.org/project/some_project) — the same forms a positional source accepts.
  • ref pins a branch, tag, full refs/… ref, or !<iid> GitLab merge request, exactly like --ref / an @<ref> suffix. Omitted, the repo's default branch is used.
  • config names the config to load from that repo's .drupalaibp/ (basename, no .json), and doubles as the entry's own name — what the picker shows in parentheses and what --config <name> selects, so unattended runs work.
  • weight, label, and description mean exactly what they mean for a local config, and the entries sort in among the local ones on the same keys. Rendering comes from the entry itself, so the picker is drawn without fetching anything; the source repo is appended to the detail panel, because choosing that row clones another repository.

Picking an entry (or naming it with --config) clones repo at ref and loads the named config from it, and the build proceeds exactly as if that repository had been passed as the source — a --config-url build, with the clone's files copied into the project. The repo that carried the pointer file contributes nothing else: none of its own files are copied. The clone is fresh every run rather than cached — pointing at another repo is a request to track it. (drupalaibp pull <repo> [name] is still the way to keep a local copy.)

Two rules keep this predictable:

  • Local wins. An entry whose name collides with a local *.json config in the same .drupalaibp/ — or with an earlier entry — is skipped with a warning. An entry missing repo or config, or with a repo that isn't a git URL, is skipped the same way; the rest of the picker is unaffected. A malformed remote-configs.json, though, is a hard error: it was written on purpose, and quietly offering fewer configs than it lists would be worse.
  • One level of indirection only. If the repo you point at has its own remote-configs.json, its entries are not followed — the chain stops there. A pick that would need a second hop is a hard error naming both repositories. This rules out pointer loops.

remote-configs.json is a reserved name: it is never treated as a config, never loaded as one, and never offered by the picker under its own name.

Git sources and refs (--config-url)

A git URL source (drupalaibp <url> or --config-url <url>) is cloned and its .drupalaibp/ read. Pin a branch, tag, or ref two ways:

  • an @<ref> suffix on the URL — drupalaibp https://git.example.org/acme/site@1.2.x;
  • the --ref <ref> flag — --config-url https://git.example.org/acme/site --ref 1.2.x.

A ref may be a branch name, a tag, a full refs/… ref, or !<iid> for a GitLab merge request (!64refs/merge-requests/64/head).

Paths inside a config resolve relative to the config file

Because the config file sits inside .drupalaibp/, every config-relative path — hooks[].script, copy_paths, db_import_path — resolves against that .drupalaibp/ directory (CONFIG_BASEDIR). Reference a script or asset that lives next to the config as "./seed.sh" (→ .drupalaibp/seed.sh), and use a leading "../" to reach the repo root. (This differs from the pre-2.x root .drupalaibp.json, where such paths were relative to the repo root.)

Three things to get right

!!! warning "1. Drupal projects need a Drupal ddev_project_type" For a Drupal build (is_drupal: true, the default), ddev_project_type must be a Drupal type — "drupal11" (default), "drupal10", "drupal12", and so on. It is passed straight to ddev config --project-type. Using "php" or another generic type for a Drupal site will not configure Drupal correctly.

!!! warning "2. Non-Drupal builds ignore the Drupal-only keys" If you set is_drupal: false, the keys profile, site_template, install_modules and recipes do nothing — there's no composer create-project, drush, or config export. A non-Drupal build still sets up DDEV, coding agents, skills, ddev_addons and hooks; drive the actual project setup with hooks (e.g. npm/composer commands run in the web container). Set ddev_project_type to a non-Drupal DDEV type such as "php".

!!! tip "3. Keep hook scripts next to the config" Store hook scripts in the same .drupalaibp/ directory as the config and reference them as "./<name>.sh" (paths resolve relative to the config file, which lives inside .drupalaibp/). Paths must be whitespace-free and are copied into the new project root before they run. Use a leading "../" only if a script deliberately lives at the repo root.

Full schema

{
  // picker metadata (only used when .drupalaibp/ holds several configs)
  "weight": 0,                       // lower sorts first; default 0, alphabetical tie-break
  "label": "",                       // shown in the picker; defaults to the file name
  "description": "",                 // long-form text in the picker's detail panel; never truncated

  // project shape
  "ddev_project_type": "drupal11",   // Drupal: drupal11/drupal10/drupal12…  Non-Drupal: e.g. "php"
  "is_drupal": true,                 // false => skip all Drupal steps
  "docroot": "web",                  // ddev config --docroot. Default: "web" (Drupal) / "." (non-Drupal)
  "launch_path": "",                 // ddev launch argument override, e.g. ":6006" for a dev-server port
  "handoff_command": "",             // run this at the very end instead of opening a shell (Enter to run, Esc/timeout to skip)

  // Drupal-only (ignored when is_drupal:false)
  "profile": "cms",                  // cms (default) | standard | minimal | template
  "site_template": "",               // site-template project name, when profile=template
  "install_modules": ["ai", "token"],
  "recipes": ["drupal/drupal_cms_blog"],   // apply-only (drush recipe) — fetch contrib recipes via composer_projects

  // agents & skills
  "agents": ["claude"],              // claude | codex | opencode — empty = bring your own
  "agent_auto_mode": false,          // true => bake each agent's unattended/auto-approval flag into `ddev <agent>`
  "extra_scripts": ["tool_api"],     // ids of "Extra Drupal Installations" (src/lib/extras/) to run
  "skills": [
    "obra/superpowers",              // "owner/repo" = all skills in the repo
    { "repo": "ivanboring/drupal-module-finder", "skills": ["drupal-module-finder"] }
  ],
  "deny_commands": ["git push", "ssh"],

  // composer
  "composer_stability": "dev",       // minimum-stability (prefer-stable always on)
  "composer_with_all_dependencies": false, // true => composer_projects require gets --with-all-dependencies (-W)
  "composer_allow_plugins": ["tbachert/spi"],
  "repositories": [
    { "type": "vcs", "url": "https://github.com/acme/private-module" }
  ],
  "composer_projects": ["drupal/ai:^1.1", "acme/private-module:dev-main"],

  // ddev add-ons
  "ddev_addons": ["ddev/ddev-redis"],

  // AGENTS.md additions
  "agents_md": ["Project rule appended to the AGENTS.md managed block."],

  // extra files/directories copied into the project root (for hook-consumed
  // assets that aren't scripts themselves), same path convention as hooks —
  // relative to the config file inside .drupalaibp/
  "copy_paths": ["./seed-assets"],

  // --config-url existing-codebase mode: the clone already IS the Drupal
  // codebase (its own composer.json/web/), so `ddev composer install` +
  // `ddev import-db` stand it up instead of scaffolding. Set at most ONE —
  // db_import: true if the dump is supplied at runtime via --db-path (too
  // large/sensitive to commit); db_import_path if it's committed in the repo.
  "db_import": false,
  "db_import_path": "",              // e.g. "db/minimal.sql.gz", relative to the config file

  // welcome-message additions (replace wins if both are set)
  "append_welcome_text": ["Read CONTRIBUTING.md before your first commit."],
  "replace_welcome_text": [],

  // custom questions (answers -> .ddev/.env)
  "questions": [
    { "env": "OPENAI_API_KEY", "label": "OpenAI API key", "type": "password" },
    { "env": "SITE_FLAVOR", "label": "Flavor", "type": "single-select-options",
      "select_options": ["blog", "shop"], "default": "blog" },
    { "env": "SITE_ENV", "type": "text", "value": "demo" }
  ],

  // hook scripts at build events (paths relative to the config file in .drupalaibp/)
  "hooks": {
    "ddev_started":       [ { "script": "./seed.sh",  "weight": 10, "where": "web" } ],
    "coding_agent_added": [ { "script": "./agent.sh", "weight": 0,  "where": "host" } ]
  }
}

Key reference

Key Applies to Notes
weight all picker order when .drupalaibp/ holds several configs — integer, lower first, default 0. Ignored for a single config.
label all name shown in that picker; defaults to the file name. Shown as Label (name) when the two differ.
description all long-form text shown in the picker's detail panel below the list, for the currently-highlighted config. Word-wrapped, never truncated. Shown only when at least one config in the set sets it.
ddev_project_type all ddev config --project-type. Drupal builds must use drupal11/drupal10/drupal12/… (see point 1).
is_drupal all default true. false skips every Drupal step.
docroot all ddev config --docroot. Default "web" (Drupal) / "." (non-Drupal).
launch_path all ddev launch argument override, e.g. ":6006" for a dev-server daemon on its own port. Default: launches the project root.
handoff_command all Run this command in the project directory at the very end, instead of opening a shell there. The operator is asked first, and only Enter runs it: Esc skips, and so does the timeout, since a run nobody is watching must not start work on its own. Skipping prints the command so it can be run by hand. Never runs without a terminal. Default: empty, which opens a shell as before.
profile Drupal cms (default), standard, minimal, template.
site_template Drupal site-template project short name (with profile: template).
install_modules Drupal enabled with drush.
recipes Drupal apply-only (drush recipe); never fetched. A drupal/<name> entry is applied from ../recipes/<name> — fetch it via composer_projects. Any other entry (e.g. core/recipes/standard) is passed to drush verbatim, so a core / already-present recipe applies with no fetch.
agents all coding agents installed into the container.
agent_auto_mode all default false. true prepends each agent's own unattended flag to its ddev <agent> wrapper (Claude --permission-mode auto, Codex --dangerously-bypass-approvals-and-sandbox, OpenCode --auto). Config-only — no CLI flag or prompt.
extra_scripts all ids of "Extra Drupal Installations" (src/lib/extras/extra_<id>.sh) to run — the config equivalent of --extras.
skills all single list; "owner/repo" (all) or {repo, skills:[…]} (["*"] = all).
deny_commands all commands the agent(s) may never run.
composer_stability composer minimum-stability; default dev.
composer_with_all_dependencies composer default false. true adds --with-all-dependencies (-W) to the composer_projects require, so Composer may update the whole dependency tree (needed when a package — e.g. a recipe you fetch here — requires newer shared deps).
composer_allow_plugins composer plugins to trust; is_drupal adds core-dev/ABP defaults.
repositories composer registered before composer_projects.
composer_projects composer fetch-only — verbatim composer require args, version constraints allowed (drupal/ai:^1.1); never applied/enabled. Fetch a contrib recipe here, then apply it via recipes.
ddev_addons all added after the site is built, then one ddev restart.
agents_md all paragraphs appended to the managed AGENTS.md block.
copy_paths all extra files/directories copied into the project root, same path convention as hooks[].script — for hook-consumed assets that aren't scripts themselves.
db_import --config-url default false. true selects existing-codebase mode (the clone IS the Drupal codebase) with the dump supplied at runtime via --db-path. Mutually exclusive with db_import_path.
db_import_path --config-url a dump committed in the repo, path relative to the config file — same existing-codebase mode as db_import, but resolved automatically, no --db-path needed.
append_welcome_text all paragraph(s) appended to the end of the print_welcome summary. Ignored if replace_welcome_text is set.
replace_welcome_text all paragraph(s) that replace the entire print_welcome summary (no admin login, no handy commands, no DDEV footer) — takes precedence over append_welcome_text.
questions all see below; answers → .ddev/.env.
hooks all see below.

Welcome-text placeholders and colors

Both append_welcome_text and replace_welcome_text support {TOKEN} substitution:

Built-in tokens:

Token Value
{PROJECT_NAME} the DDEV project name (lowercased directory name)
{SITE_URL} https://<project>.ddev.site
{DRUPAL_USERNAME} admin
{DRUPAL_PASSWORD} admin
{DRUPAL_LOGIN_URL} {SITE_URL}/user

Env-var tokens: any questions[].env answer is addressable by its own name, e.g. {OPENAI_API_KEY}, {SITE_FLAVOR}.

{
  "append_welcome_text": [
    "Environment: {SITE_ENV}  •  Flavor: {SITE_FLAVOR}",
    "Log in at {DRUPAL_LOGIN_URL} with {DRUPAL_USERNAME} / {DRUPAL_PASSWORD}."
  ]
}

!!! warning "Unknown tokens print literally" A {TOKEN} that isn't a built-in or a questions[].env name is left untouched in the output (not emptied) — this surfaces a typo instead of silently hiding it.

!!! warning "Secrets land on the terminal" A password-type question's answer, if referenced by a welcome-text placeholder, is echoed to the terminal in the final build summary. Don't reference a genuinely sensitive value this way unless you're fine with it appearing in that output.

Colors: {BOLD}, {DIM}, {GREEN}, {YELLOW}, {RED}, {RESET} expand to the installer's own color codes when connected to a real terminal, and to nothing when output isn't a TTY (piped/redirected), so logs stay clean. Every paragraph also gets an automatic trailing reset, so an unclosed color token can't bleed into the next paragraph or the rest of the terminal.

{
  "append_welcome_text": "{BOLD}{GREEN}Welcome to the ACME demo!{RESET} Docs: https://example.com/handbook"
}

Questions

Each question object uses env (the environment-variable name — a valid identifier), label, type, and optionally default, select_options, value, depends_on.

  • Types: text, password, single-select-options, multi-select-options.
  • select_options — the choice list for the two select types.
  • default — pre-fills an editable prompt (still shown).
  • valuepre-answers the question, so it is not shown. Use default (not value) if you want a question prompted with a suggestion.
  • Answers land in .ddev/.env (mode 600, gitignored). Never put a real secret in value — leave it for the user to type.

Conditional questions (depends_on)

A question can be shown only when an earlier question's answer matches a condition:

{
  "env": "REDIS_TTL",
  "label": "Redis cache TTL (seconds)",
  "type": "text",
  "default": "3600",
  "depends_on": { "env": "USE_REDIS", "equals": "yes" }
}
  • depends_on.env must name a question that appears earlier in the questions array. A forward reference, self-reference, or a name that doesn't match any earlier question is invalid: it's ignored with a warning and the question is asked unconditionally instead of being dropped.
  • Condition kinds (exactly one required): "equals": "<value>", "not_equals": "<value>", or "in": ["a", "b", ...] (the dependency's answer is one of the listed values — useful when a select has more than two meaningful options).
  • A question whose condition isn't met is not shown, and its ANSWERS value is set to an empty string — still written to .ddev/.env as KEY="", so hooks and welcome-text tokens can test for emptiness rather than the variable being entirely undefined.
  • value still short-circuits a question before depends_on is ever evaluated for it — a pre-answered question is never conditionally skipped, it's simply always answered from value.

!!! warning "Any depends_on disables .ddev/.env reuse for the whole block" Normally a question already answered in a previous run (its key already present in .ddev/.env) is reused silently instead of being re-prompted — this is what makes a re-run in management mode not re-ask everything. A conditional question's visibility has to reflect the current answer of its dependency, not a possibly-stale one from a previous run — so the presence of any depends_on anywhere in a config's questions disables that reuse for the entire array, on every run, including questions with no depends_on of their own and password questions. A config with no conditional questions is unaffected. If you add one conditional question to a config, every question in that config (including secrets) will be re-prompted on every future re-run of that config.

Hooks and events

Each event name maps to a list of { script, weight, where }:

  • weight — integer; lower runs first.
  • wherehost, web (default; runs in the DDEV web container via ddev exec), or a named DDEV service.

!!! warning "Pre-start hooks must use where: host" web/service hooks need the container running. A hook on a pre-start event (before_ddev_start, ddev_addon_added, coding_agent_added, or anything before ddev_started) must set "where": "host".

!!! warning "Question answers are only in the container" Answers from questions are written to .ddev/.env, which DDEV injects into the web container — not the host. A "where": "web" hook sees them as environment variables ($OPENAI_API_KEY, …); a "where": "host" hook does not. If a hook needs a question's answer, run it in web.

Available events: before_<step> / <step>_done for every pipeline step, plus the semantic events before_ddev_start, ddev_started, coding_agent_added, ddev_addon_added, skill_added, and ask_questions. The most common are ddev_started (the site is up — run drush/composer/npm in web) and coding_agent_added (host-side — tweak AGENTS.md).

!!! note "Hook script and copy_paths staging timing (Drupal fresh-scaffold builds)" For is_drupal: true fresh-scaffold builds (without db_import or db_import_path), copy_paths and most hook scripts are staged after Drupal scaffolding completes (create_drupal_site_done), not before. This is because ddev composer create-project refuses a non-empty project directory.

**Exception: hook scripts on early events.** A hook script registered on an
event that fires **before** Drupal's scaffold
(`before_ddev_start`, `ddev_started`, `coding_agent_added`,
`ask_questions`, or `before_create_drupal_site` itself)
is staged early, but physically lives **only** at
`.ddev/drupalaibp-hooks/<configured-path>` in the final scaffolded
project — **not** at the `script` path written in the config.
This is a real, user-visible difference from every other hook script
(which does land at its configured path in the project root):
an early hook is classified as such and never additionally copied to where
a late hook would be. If you inspect the scaffolded project, an early
hook's script will only be found under `.ddev/`, not in the root.

**Existing-codebase mode and non-Drupal builds are unaffected:**
When `db_import` or `db_import_path` is set, or when `is_drupal: false`,
all hook scripts and `copy_paths` use the original timing — no
deferral, no `.ddev/`-only staging.

Importing Drupal config (.drupalaibp/config/)

Drupal-only. Drop a .drupalaibp/config/ directory (a config/ subdirectory inside .drupalaibp/, alongside the config JSON) containing exported Drupal config YAML, and after the site is installed and provisioned the installer runs a partial drush config:import from it:

my-launch-dir/
└── .drupalaibp/
    ├── config.json             # the installer config
    └── config/
        └── system.site.yml     # e.g. override the site name

It's a partial import — the provided config is applied on top of the installed site; config not present in the directory is left alone (nothing is deleted). This runs after provision_from_config, so config that references modules from install_modules imports cleanly. Ignored for is_drupal: false builds and when the directory has no *.yml. See docs/examples/drupal/ for a working system.site.yml.

Good to know

  • selenium / toolbelt / dtk are not flags — they're ordinary provisioning: Selenium as a ddev_addons entry (a built-in handler makes Chrome headed with noVNC on port 7900), Toolbelt/DTK as composer_projects + install_modules.
  • AI Best Practices + core-dev (drupal/ai_best_practices, drupal/core-dev, and a starter phpunit.xml) are built-in defaults for every is_drupal: true build — you don't list them. ai_best_practices is installed and its Composer plugin allow-listed automatically.
  • The experimental Tool API + Toolbelt (drupal/tool + drupal/tool_belt, so agents can run Drupal actions via drush tool:*) is suggested during an interactive build (or --tool-api). In a config, add it through composer_projects + install_modules (+ tbachert/spi in composer_allow_plugins).
  • Build order: DDEV starts vanilla, the Drupal site is created, then ddev_addons are added and applied with a single restart (some add-ons write files outside .ddev/ that would otherwise break composer create-project).
  • To replace the whole AGENTS.md block content, drop an overrides/agents_md.json next to the config.

Examples

See the runnable examples and their hook scripts under docs/examples/:

  • docs/examples/drupal/is_drupal: true, Drupal CMS on Drupal 11, with a private repo, version-constrained composer projects, modules, a recipe, the Selenium + Redis add-ons, password + select questions, and .drupalaibp/ hook scripts on ddev_started and coding_agent_added.
  • docs/examples/storybook/is_drupal: false, a Node/Storybook project built entirely through where: web npm hooks; every question type drives the npm setup.