Design document

SPEC.md

How cwp is built

cwp as it is — the design of the released tool.

This is the design document, not the manual. It describes mechanisms and the reasoning behind them; it is not a guide to using the commands, and where it discusses behaviour the shipped binary is the authority.

Rendered verbatim from SPEC.md on the main branch. Nothing on this page is edited, summarised or reordered — if the file changes, this page changes with it.

cwp — Specification

Status: Current as of 0.4.0 Owner: BERNSTEINKRAFT n.e.V. Successor: docs/ARCHITECTURE.md — the v1.0 target architecture (F-040 – F-043). This document is authoritative for cwp as it is; that one is authoritative for cwp as it is meant to become. Where they disagree, this one wins until the corresponding phase lands, at which point this one is updated. The safety invariants are identical in both, deliberately.


1. Purpose

cwp (Controlled WordPress) is a CLI that manages the lifecycle of WordPress projects which are

  • hosted on Cloudron (Hetzner server, Docker-based apps), and
  • developed locally with DDEV.

It is middleware, not a framework. It orchestrates two existing tools — the Cloudron CLI and DDEV — and adds the WordPress- and Bricks-specific glue that neither of them provides. It never reimplements what those tools already do well.

On the name. The C is controlled, not Cloudron. Moving a WordPress site between environments is normally an unpredictable sequence of half-remembered commands, and what this tool contributes is not transport — the tools above already do transport — but control over the movement: a stated direction, a guard that refuses, a restore point taken first, a verification afterwards, and a test suite that asserts each of those still happens. Cloudron is the host it happens to run against today; docs/ARCHITECTURE.md §9 makes it one provider of three, at which point a name that meant Cloudron would be actively wrong.

The primary human user is a solo developer managing 4–6 parallel projects. The primary machine user is Claude Code, which drives cwp from within a project repository.


2. Design principles

  1. Asymmetric data flow. Database and uploads flow down (remote → local). Code flows up (local → remote). Every command that violates this direction requires an explicit flag and an interactive confirmation.

    Sharpened 2026-07-31 (F-031). The extent is what this principle is about, not the table. It guards against a database write whose blast radius is “everything” — where nobody can enumerate beforehand what will differ afterwards. That is cwp db push (§5.6), and it is why that command is deliberately awkward. A write of individually named items is a different operation and always has been: cwp bricks import has replaced global classes on a protected environment since 0.4.0 under the full §5.4 guard set, and nobody read it as a breach. So, stated as it should have been from the start:

    Bulk database state flows downward. Individually named items may flow upward, under the full guard set, with the affected items listed before the write.

  2. Delegate, do not reimplement. DDEV owns the local container. Cloudron CLI owns remote transport. WP-CLI owns WordPress. cwp owns sequencing, safety and project conventions.

    Where the line runs — stated 2026-07-31, from field feedback. This principle forbids reimplementing what those tools already do. It does not forbid cwp from looping.

    • Logic is deciding what the target state should be: which class is obsolete, what value a token takes. That belongs to a person.
    • Transport is establishing a state that has already been declared: diffing, ordering, calling N abilities in sequence, collecting the failures.

    The rule that keeps the second one glue: cwp invents no values. It reads a file, compares, calls. The moment cwp sets a default or generates a name, the line has been crossed. F-020 and F-021 already run on that side of it, and F-033 is the case the sentence was written for.

  3. Leave no dependency behind. cwp init generates a working DDEV setup — including a native DDEV provider — so the project remains usable by someone who does not have cwp installed.

  4. Idempotent. Running any command twice produces the same result. init on an existing project repairs rather than overwrites.

  5. Machine-readable. Every command supports --json. Exit codes are stable and documented. Human output goes to stderr; JSON goes to stdout.

  6. Explicit over clever. No hidden magic, no implicit environment selection. The environment is always named or defaults to dev.

  7. KISS / LEAN. Minimal dependency surface. No plugin system — shell hooks cover extensibility.


3. Stack

Decided — not open for revision during implementation.

  • Runtime: Node.js ≥ 22, ESM only
  • Language: TypeScript, built with tsup to a single ESM bundle
  • CLI framework: commander
  • Prompts: @clack/prompts
  • Config: yaml + zod for schema validation
  • Subprocess: execa
  • Tests: vitest

Distribution: npm, scoped as @bernsteinkraft/cwp, binary name cwp. Installed globally (npm i -g @bernsteinkraft/cwp), not per project.

Explicit non-goals: no Composer/Bedrock integration, no CI runner, no multi-user/team features, no support for hosts other than Cloudron.

The last of those is superseded by F-041 when it lands: Cloudron becomes the first implementation of a provider interface rather than an assumption. It was the right non-goal to write — an interface with one implementation is speculative generality — and it stops being right once a second one exists to keep it honest. Until then, this section stands.


4. Configuration

Two layers. Both are YAML. Neither is ever written by hand in normal use — cwp init and cwp config set maintain them.

4.1 Project config — .cwp.yml (committed to Git)

version: 1
name: example
docroot: public
php: "8.3"
plugin_slug: example-site   # site-plugin directory; defaults to <name>-site

environments:
  dev:
    cloudron_app: example.com   # Cloudron app location (= --app value)
    url: https://example.com
    package: managed                     # managed | developer
    protected: false
  prod:
    cloudron_app: www.example.com
    url: https://www.example.com
    package: managed
    protected: true                      # blocks all upward writes without --force

# Paths (relative to project root, so including the docroot) that `cwp deploy`
# pushes upward.
tracked:
  - public/wp-content/plugins/example-site

pull:
  uploads: proxy        # sync | skip | proxy
  scrub: true
  keep_admin_email: dirk@example.com   # this user survives scrubbing
  keep_roles:                          # these roles survive scrubbing
    - administrator
    - editor

bricks:
  enabled: true
  child_theme: snn-brx-child-theme   # detected; any child of Bricks (F-029)
  export_dir: bricks
  wp_user: 1            # optional — who the ability transport runs as (§9.3)

hooks_dir: .cwp/hooks

4.2 Machine config — ~/.config/cwp/config.yml (never committed)

version: 1
cloudron_host: my.example.com

defaults:
  uploads: proxy
  scrub: true
  backup_before_push: true
  snapshot_before_pull: true

projects:
  example: ~/Code/example/example.com

The projects registry is maintained automatically by cwp init and enables cwp projects and future cross-project commands.

4.3 Remote path resolution

The two Cloudron WordPress packages place wp-content in different locations. This must be resolved from environments.<env>.package:

package wp-content path
managed /app/data/wp-content
developer /app/data/public/wp-content

cwp doctor verifies the configured value by probing the remote filesystem and reports a mismatch rather than guessing silently.


5. Commands

5.1 cwp init

Interactive. Safe to re-run.

  1. Ask for project name (default: current directory name).
  2. Ask for Cloudron app / domain for the dev environment (default: current directory name if it looks like a domain).
  3. Ask for the site-plugin directory (default: <name>-site). It is asked separately because the project name is often the domain while the plugin keeps a short slug — exampleexample-site.
  4. Ask for Cloudron host if the machine config has none.
  5. Detect the package type (managed / developer) by probing the remote.
  6. Detect the remote PHP version and offer it as the DDEV PHP version.
  7. Run the doctor checks; abort with actionable hints if a hard dependency is missing.
  8. Scaffold (see §6). On an existing project this restores missing files and repairs .cwp.yml in place, reporting each change (see §6.1).
  9. Register the project in the machine config.
  10. Print next steps.

Flags: --name, --app, --plugin-slug, --env, --non-interactive, --json.

Defaults on a re-run (B-008). Steps 1–3 describe a fresh project. When the current directory already holds a valid .cwp.yml, that file is the source of the defaults — flag > existing config > derivation from the directory name. Not doing this made a re-run offer back a name and plugin slug the project never had: in example.com/, name became the domain and plugin_slug became example.com-site, which does not validate. “Safe to re-run” has to mean the tool offers back what it wrote.

Inherited: name, plugin_slug, docroot, php, bricks.enabled, pull.keep_admin_email, and the environment’s cloudron_app and url (the recorded URL is kept when the app is unchanged, since it may legitimately differ from https://<app>). The remote probes in steps 4–5 degrade to what the config records rather than to a generic guess, so an offline re-run does not contradict the file.

Not inherited: the app for an environment the config does not define. Adding prod to a project that only has dev must not silently reuse the dev app.

The config is read from the current directory only — never by walking upward — so running init one directory too deep cannot inherit a parent project’s settings.

Deriving the plugin slug (B-009). Naming the project directory after the domain is the convention here, so <name>-site is not a usable default on its own: it yields example.com-site. The derivation takes the first domain label and sanitises it — example.comexample-site. An explicit --plugin-slug still wins; a slug like shop-site for a project named example.com is derivable from nothing.

5.2 cwp doctor

Read-only diagnostics. Each check reports ok | warn | fail plus, on failure, a copy-pasteable fix. Checks:

Check Fix hint on failure
Node ≥ 22 brew install node
Docker runtime reachable brew install --cask orbstack, then start OrbStack
DDEV installed brew install ddev/ddev/ddev
mkcert CA installed mkcert -install
Cloudron CLI installed npm i -g cloudron
Cloudron logged in cloudron login <host>
Machine config present & valid cwp init
Project config present & valid cwp init
Remote app reachable (cloudron list contains app) check cloudron_app value
Remote wp responds cloudron exec --app <app> -- wp --info
PHP parity local vs remote adjust .ddev/config.yaml
package value matches probed remote path correct .cwp.yml
WordPress core present in the docroot ddev wp core download
DDEV project running ddev start
Uploads fallback installed (proxy mode, §7.1) cwp pull --uploads-only, then ddev restart
Uploads fallback serves a missing file (one request, expecting 200) ddev restart; check the remote serves it unauthenticated

Exit code 0 only if no fail.

5.3 cwp pull [env] (default dev)

Downward sync. The workhorse.

  1. Guard: confirm the local database will be replaced.
  2. Snapshot the local database (pre-pull-<env>-<timestamp>) unless disabled. Placed after the guard so a refused pull has no side effects, and before any destructive step so the restore point genuinely exists. A failed snapshot aborts the pull — unlike the best-effort steps below, this one is the safety net itself. Skipped with a reason when there is no local database yet or the scope excludes it (--uploads-only).
  3. Remote: wp db export to a temp path inside the app container.
  4. Transfer down via cloudron pull, then delete the remote temp file.
  5. ddev import-db
  6. ddev wp search-replace <remote_url> <local_url> --all-tables --skip-columns=guid (WP-CLI handles PHP serialization correctly — never use raw SQL here, Bricks stores element trees as serialized JSON in postmeta.)
  7. Uploads according to pull.uploads (§7).
  8. Scrub according to pull.scrub (§8).
  9. Bricks post-import steps (§9).
  10. ddev wp cache flush
  11. Run post-pull hook if present.

Flags: --uploads <mode> (sync|skip|proxyskip replaces the former --no-uploads, which is dropped as redundant), --no-scrub, --no-snapshot, --db-only, --uploads-only, --yes, --json. --db-only and --uploads-only are the two scope switches; they are mutually exclusive.

Why the snapshot is not optional by default (F-017). Every other guard in cwp protects the direction that already has a backup: the remote database is covered by cloudron backup create, remote code by the same. The local database is covered by nothing, and on a Bricks site it is the only copy of the design work — layouts, templates, global classes and theme styles are all database state that the repository deliberately does not carry (§6). --no-snapshot and defaults.snapshot_before_pull: false exist for the fast path; the default is true because the expensive case is common and the loss is permanent.

5.4 cwp push [env] and cwp deploy [env]

push transfers tracked paths upward. deploy = push + remote post-steps (wp cache flush, Bricks CSS regeneration, post-deploy hook).

Safety, in order:

  1. If environments.<env>.protected is true → refuse unless --force.
  2. Resolve tracked into concrete transfers. An empty list, a path that does not exist, one outside the docroot, or one escaping the project is refused — “push everything” must not be reachable by omission.
  3. Show what would move and require confirmation unless --yes.
  4. Run the pre-push hook; a non-zero exit aborts.
  5. If defaults.backup_before_pushcloudron backup create --app <app>.
  6. Transfer with cloudron sync push, then verify against the remote file count.

Revised 2026-07-25. The original order had the backup at step 2 and the confirmation at step 3. In practice that meant a refused push had already run the user’s hook and taken a real remote backup — and since a non-interactive caller without --yes is always refused, every such call left a backup behind. The invariant that matters (“a restore point exists before any upward write”) holds either way, so confirmation now comes first and a refused push has no effects at all.

Step 3 is not a diff. cloudron sync has no dry-run mode, so what is shown is the tracked paths, their local file counts and their remote targets. cwp does not claim to know which individual files differ.

cloudron backup create blocks — verified 2026-07-28 against a live Cloudron app (CLI 8.2.6). The command returned after ~15s and the finished backup was already listed by cloudron backup list at that point, so the restore point genuinely exists before the transfer starts. The assumption the ordering rests on holds.

Cloudron sync syntax — verified 2026-07-24 against the Cloudron CLI docs. The subcommand comes first, then --app, then source and destination:

cloudron sync push --app <app> <local_src>  <remote_dst>
cloudron sync pull --app <app> <remote_src> <local_dst>

sync is documented to transfer only changed files (size + mtime, rsync-style), with rsync’s trailing-slash semantics: a trailing slash on the source syncs its contents into the destination; without it, the source directory itself is nested inside the destination.

Correction, verified 2026-07-25 against CLI 8.2.6: cloudron sync pull does not work. It lists every file, prints Syncing N file(s)..., exits 0 and writes nothing — with and without a trailing slash, with --force, and under a pty (B-003). cloudron push|pull moves single files correctly but cats a directory into a 0-byte file.

cwp therefore transfers directory trees downward as a tarball: remote tar czf /tmp/… -C <dir> .cloudron pull the archive → extract locally → delete both copies. Every such transfer is verified against the remote file count afterwards, because a transport that fails by exiting 0 over an empty destination is exactly what this cost us once already.

cloudron sync push works — verified 2026-07-25 against CLI 8.2.6 (tested into the container’s /tmp, never /app/data). The upward direction completes and prints Sync complete., which the broken pull direction never does: that message is the difference between the two. Trailing-slash semantics hold as documented — with a slash the contents are placed in the destination, without it the directory is nested inside it. Single-file cloudron push works too.

Two rules follow for push/deploy:

  1. COPYFILE_DISABLE=1 is mandatory. cloudron sync shells out to tar, and macOS tar packs extended attributes as AppleDouble sidecars: an unguarded push of three files landed six on the remote (._a.txt and friends). With COPYFILE_DISABLE=1 in the environment, exactly three land. Pushing ._* files into a WordPress plugin directory on a live site is not acceptable, so the environment variable is set for every upward transfer.

  2. Chown after transfer (B-006). cloudron sync push enters the container as root and leaves files owned by the local uid — 501 on macOS, which does not exist in the container — and directories owned by root, while the rest of wp-content is www-data. The code still runs (modes are 644/755) but WordPress cannot update or delete it, and a pushed path it needs to write to would break. Every transfer is followed by chown -R www-data:www-data <target>, best-effort: the files are already there, so a failure warns rather than failing the push.

  3. Verify after transfer, never trust the exit code. Upward transfers are verified by digest, not by counting files (B-007). cwp reads md5sum for every file below the remote target and compares it against the local tree: a file that never landed, or landed with different contents, fails the push and is named. Extra remote files are expected when adding and fail under --delete. A remote without md5sum warns and falls back to the count.

    A count cannot tell a completed transfer from a no-op over an already-populated directory. Worse, cloudron sync compares size and mtime, so a remote file corrupted to the same length with its mtime preserved is reported as Already up to date and never resent — verified on a live Cloudron app, where the count check passed and the digest check caught it. For the same reason, re-running a failed push does not repair it: the local file must be touched, or the remote copy deleted.

Source: https://docs.cloudron.io/packaging/cli/.

The delta claim is confirmed — verified 2026-07-28 against a live Cloudron app (CLI 8.2.6). Re-pushing an unchanged tree prints Already up to date. and moves nothing; after touching one file and adding another, it listed exactly those two and printed Syncing 2 file(s).... So sync push is incremental.

But it is additive, not a mirror (B-005). A file deleted locally is never removed from the remote — it stays and, in a plugin directory, keeps being loaded. cwp push therefore unions state rather than deploying it, and the remote file count can exceed the local one while cwp still reports success.

Resolved with --delete. The CLI has a native sync push --delete (“Delete remote files not present locally”), and cwp exposes it as the opt-in:

  • Default stays additive. Deleting remote files as a side effect of a routine deploy is precisely the surprise this tool exists to prevent. But the default is no longer silent: when the remote holds files the tracked path does not, cwp reports the count and names the flag.
  • --delete mirrors, and is treated as the destructive operation it is. It sits behind every existing guard — protected environment, confirmation, remote backup — and the interactive prompt states that files will be deleted rather than leaving it implied by a flag typed moments earlier.
  • --dry-run --delete lists the files it would remove. The remote listing is a read-only probe, so it runs under --dry-run (§12) and the plan is truthful.
  • cwp never issues a remote rm for this. Deletion is the CLI’s own, so there is no cwp-authored destructive command running inside a live container.

Every run ends with a summary — target, mode, backup, files sent, files deleted, and each deleted path by name. The numbers are parsed from the CLI’s own output (Syncing N file(s), deleting M file(s)..., delete: <path>), because what the tool reports it did beats what cwp can infer from a file count (B-007).

A database push is a separate, deliberately awkward command: cwp db push <env> — requires typing the environment name to confirm and always takes a backup first. The protected-environment override is the same --force used by push/deploy, not a second bespoke flag: there is one way to say “yes, this environment is protected, proceed”, and the database-specific extra hurdle is the typed-name confirmation. (The earlier design used a distinct --i-know-what-i-am-doing flag; that was dropped during the README design review as gratuitous inconsistency.)

5.4a cwp fetch [env]

Downward code fetch: third-party themes and plugins. They are gitignored and cwp otherwise moves code upward only, so a freshly initialised project cannot render the site without them.

Explicit and opt-in — routine pull moves data, not code. It never writes upward, and it must never overwrite the code you write:

  1. List the remote themes / plugins directories.
  2. Skip every directory that is inside a tracked path.
  3. Confirm before overwriting local directories that already exist (unless --yes; refused under --json without it).
  4. Transfer each remaining directory individually with cloudron sync pull — individually because cloudron sync has no exclude option, so a whole-directory sync would clobber the site plugin.

Flags: --themes, --plugins (mutually exclusive scope switches; neither means both), --yes, --dry-run, --json, --verbose.

A declarative inventory of what should be installed is F-021 (plugins snapshot|apply, 0.4.0); fetch is the transport that makes the rest usable.

5.5 cwp db <subcommand>

  • snapshot [name] — wraps ddev snapshot. Default name is a timestamp.
  • restore [name] — wraps ddev snapshot restore. Interactive picker if no name. This is the documented recovery path when a cwp pull replaced local work worth keeping: restore the pre-pull-<env>-<timestamp> snapshot the pull took on its way in (§5.3, F-017).
  • list — list snapshots
  • rm <name>
  • export [file] / import <file>
  • push <env> — see above

Snapshots are database only — uploads are not included. Document this prominently; it is the most likely source of confusion.

5.6 cwp wp <args...>

Pass-through to WP-CLI. Local by default, remote with --env <env>.

The -- separator is mandatory. Everything after it is forwarded to WP-CLI verbatim; everything before it belongs to cwp. This is what keeps cwp’s own flags (--env, --json, --verbose) from ever colliding with WP-CLI flags such as --path, --format=json or --allow-root. Implemented with commander’s enablePositionalOptions() / passThroughOptions() so no WP-CLI argument is parsed by cwp. Invoking cwp wp without a -- is a usage error with a fix hint, not a best-effort guess.

cwp wp -- plugin list
cwp wp --env dev -- plugin list

Remote invocations run through cloudron exec --app <app> -- wp <args>.

This command is a conduit, and that has three consequences (F-010). stdio is inherited, not captured, so wp shell and wp db cli stay interactive and output streams as it happens. WP-CLI’s exit code is forwarded verbatim, even where it collides with the §12 table — a pass-through that rewrites exit codes is useless in a script, and cwp’s own refusals all happen before WP-CLI starts. And there is no --json: stdout is WP-CLI’s payload, so the envelope would corrupt it, and --format=json is WP-CLI’s own answer. This is the single documented exception to §12.

cwp wp --env <protected> is not refused. Safety rule 2 governs upward transfers of files and databases; cwp cannot classify WP-CLI subcommands, and a --force on cwp wp -- plugin list would be a reflex rather than a guard. The remote target is named on stderr before the command runs instead.

--allow-root — verified 2026-07-24 against the Cloudron WordPress package docs. Cloudron ships a wp wrapper that is “pre-setup to run as the correct user” (www-data), so plain wp works in the web terminal without --allow-root, and the docs never mention the flag. However, cloudron exec itself enters the container as root, and whether the wrapper still drops privileges in a non-interactive exec invocation is not documented. We therefore do not hardcode either behaviour: on first remote use cwp probes with wp --info, and only if WP-CLI refuses with its “run as root” error does it retry (and thereafter cache) with --allow-root. The result is stored per host in the machine config. This is the capability check the design calls for — it degrades to a clear message rather than guessing.

The open question is now answered — verified 2026-07-28 on a live Cloudron app (WordPress 3.18.2, CLI 8.2.6). The wrapper at /usr/bin/wp is literally sudo -u www-data -i -- /app/pkg/wp --path=/app/code/ "$@", and it drops privileges non-interactively too: cloudron exec -- id reports uid=0(root) while cloudron exec -- wp eval 'echo exec("id");' reports uid=33(www-data). So --allow-root is not needed, and the probe correctly resolves to false. The probe stays — it costs one call, it is cached, and it keeps the tool honest against packages that behave differently. Note also that the wrapper supplies --path=/app/code/ itself: WordPress core lives at /app/code, not at the /app/data docroot used for transfer targets, so cwp must keep not passing --path to remote wp.

Remote wp-content lives at /app/data/wp-content (managed) or /app/data/public/wp-content (developer); see §4.3.

5.7 Convenience

  • cwp shell [env]ddev ssh locally, cloudron exec remotely
  • cwp logs [env] [-f]
  • cwp open [env] — open the local or remote URL
  • cwp status — one-screen overview: env URLs, DDEV state, last pull, snapshot count
  • cwp projects — list registered projects on this machine
  • cwp config get|set — read/write project or machine config

5.8 cwp bricks <subcommand>

Bricks-specific operations, all of them driving Bricks 2.4’s own abilities through the wp eval-file transport described in §9.2, as the user §9.3 resolves. Every subcommand capability-checks first and degrades to an instruction, never to a silent failure.

[env] omitted means the local DDEV site, as it does for cwp open — the environment map in .cwp.yml describes the remotes, and the local site is the one an AI client is normally pointed at.

  • export [env] / import [env] — the global transfer round-trip (F-020). import against a remote is an upward write and carries the full §5.4 guard set: protected refuses without --force, confirmation, remote backup.

  • The design-system snapshot (F-035). Element trees on posts are revisioned and recoverable through bricks/restore-revision; global classes, variables, colours and theme styles are not, so a delete is immediate and final. Before a destructive write against the local site, cwp bricks export runs into .cwp/bricks-snapshots/<timestamp>/ — after the confirmation, so a refused command has no side effects, and before the destructive step. A failed snapshot aborts the operation: this is F-017’s rule one level down, and a safety net that is silently not spanned is worse than none.

    Two callers: import --replace, and ability when the ability declares destructive and its category is one an export can restore. The general -purpose door is how a global class gets deleted today, and a net the plainest route walks around is not a net. An unrecognised bricks-* category snapshots; a non-Bricks namespace never does, because a Bricks export cannot restore it.

    A remote takes no snapshot — the Cloudron backup in the §5.4 guard set already covers it, the same distribution §5.3 uses for the database. Opt out with --no-snapshot or defaults.snapshot_before_bricks_write: false. Snapshots accumulate and are never pruned, as cwp db snapshots do (§5.5).

  • audit [env] [--scope <scope>] [--severity <level>...] [--fast] — a read-only surface over bricks/audit-design-system (F-034): orphan references as error, unconditioned theme styles as warning, unused classes, variables and colours as info. No guards of any kind, because nothing is written, and the same command against a remote as against the local site.

    It reports and never acts, and the exit code stays 0 even with errors among the findings: this is a diagnosis of the site, not a verdict on the command. The scan is one pass and does not page (verified 2026-07-31 against 2.4-beta2: the ability takes scope and skipPostScan and nothing else) — a truncated answer would still be reported rather than silently kept, which is B-017.

    --fast passes skipPostScan, and cwp warns on every such run: “unused” is computed from the post scan, so skipping it makes those findings absent, not zero. Reporting a clean audit of a site nobody looked at is the failure class §7.1 and B-019 are about, one command over.

  • mcp [env] [--client <name>] [--print] [--rotate] — generate the MCP client config for one of the 19 clients Bricks supports, minting the application password through wp user application-password (F-027, §9.4). The secret never enters the repository. For claude-code the emitted form is a command line, so cwp offers to run it behind a confirmation; it edits no client config file itself.

  • post-types list|enable|disable <slug> [env] — read and write bricks_global_settings.postTypes, the setting that decides whether the builder opens for a custom post type (§9.1, F-027).

cwp bricks is not a pass-through like cwp wp. Each subcommand has an opinion about guards and output, so it keeps the §12 contract in full — including --json — unlike the three conduit commands (§5.6).


6. What cwp init scaffolds

<project>/
  .cwp.yml
  .cwp/
    hooks/                     # post-pull.sh, pre-push.sh, post-deploy.sh (samples, +x)
    tmp/                       # gitignored
    bricks-snapshots/          # design-system restore points (F-035), gitignored
  .ddev/
    config.yaml                # php 8.3, type wordpress, docroot from config
    providers/cloudron.yaml    # native DDEV provider (see §10)
    nginx/
      cwp-uploads-fallback.conf  # uploads proxy fallback (see §7.1), committed
  public/                      # docroot; WP core is gitignored
    wp-content/
      plugins/<slug>/          # the site plugin — YOUR code lives here
        <slug>.php               # <slug> = plugin_slug, default <name>-site
        inc/
          elements/            # custom Bricks elements
          hooks/
        assets/
        README.md
      mu-plugins/
        cwp-mail-guard.php     # generated by scrub, gitignored
  bricks/                      # exported Bricks templates/components as JSON
  .gitignore
  CLAUDE.md
  README.md
  FEATURES.md
  BUGS.md
  CHANGELOG.md
  VERSION.txt

Critical convention to encode in CLAUDE.md and the site-plugin README: WordPress has no grandchild themes, so a child theme’s own updates overwrite anything written into it. Therefore no project code is ever written into the theme directory — it belongs in the site plugin. Custom Bricks elements are registered from the plugin via \Bricks\Elements::register_element() on the init hook.

That rule holds for every child theme and is stated without naming one (F-029). cwp init detects the installed child of Bricks — by Template: bricks in its style.css, which is WordPress’s own definition and so matches SNN-BRX, Bricksable or a hand-rolled theme equally — records it as bricks.child_theme, and names it in the generated documents. SNN-BRX additionally auto-updates from GitHub, and that clause appears only for SNN-BRX, because it is the reason editing that theme is unusually costly rather than merely unwise.

.gitignore excludes: WordPress core, third-party plugins and themes, public/wp-content/uploads, .cwp/tmp, .cwp/bricks-snapshots, .ddev/.*, generated mu-plugins. Not .cwp/ wholesale — hooks and project scripts live there and are committed, which is why the snapshot directory also ignores itself (§5.8, F-035) rather than relying on a rule an older project never got.

6.1 Re-running init: what “repairs” means

Every file above is written only when absent, so a re-run restores what is missing and never touches what exists. .cwp.yml is the one exception: it is the file cwp itself has opinions about, so it is brought up to date in place.

Repairs currently applied:

key repair
pull.truncate_tables remove snn_301_logs, snn_404_logs, snn_search_logs — tables that never existed (B-001)
pull.truncate_post_types add the SNN-BRX log post types when the key is absent and that child theme is installed (F-029); append snn_search_logs and snn-agent-history when the list is verbatim what cwp wrote (B-011)
pull.truncate_taxonomies add the SNN-BRX log taxonomies under the same condition (B-011, F-029)

The phantom-table cleanup applies to every project, because it corrects a mistake cwp made. The two additions apply only where SNN-BRX is present, because they describe one stack’s post types — and an unknown answer adds nothing, which is the repair path’s standing rule.

The truncate_post_types append is the only repair that adds to a list the file already has, and it is narrowly guarded because of it. cwp init writes that key out explicitly, so every project scaffolded before B-011 has the old four-name list pinned and a corrected schema default can never reach it — the pinned-default trap B-001 identified, hitting B-001’s own fix. The append therefore fires only when the existing list contains all four of the previously shipped defaults, which is verbatim what cwp wrote; a list anyone has curated fails that test and is left exactly as it is.

Rules the repair path holds to:

  • Report every change. Each repair is its own step in the output. A config rewritten silently would be worse than one left stale.
  • Never change a setting the user chose. It only removes entries that cannot match anything and adds keys that were absent. An unrecognised table name is kept — it may be the user’s own.
  • Never pin defaults. The repaired input is written back, not the parsed config. Serialising the parsed result would materialise every schema default into the file, and a pinned default no longer follows a corrected one — which is exactly how the phantom table names survived. The repair must not reintroduce the mechanism it exists to clean up.
  • Decline rather than half-fix. A config that cannot be read, cannot be parsed, or would not validate after the repair is left exactly as it is and reported as nothing to repair. init is not the place to fail on a broken config; doctor and the normal load path say so properly, with a fix hint.

Note that the repair is about the file, not behaviour: the schema’s per-field defaults already fill truncate_post_types in at load time, so a config without the key scrubs log posts correctly. What was wrong was a file listing tables that can do nothing while giving no hint that the post-type setting exists.

6.2 Where custom post types are defined

Decided 2026-07-28. Custom post types are registered in code, in the site plugininc/post-types/, on the init hook. Not in .cwp.yml, and not in the database.

This is not a style preference; it follows from the data-flow axis in §5. Database and uploads move downward only, code moves upward. So every “where does this live” question reduces to one test: must it be authored locally and reach production?

  • Defined in the database — CPT UI, the SNN-BRX post-type manager, ACF post types — the definition sits in wp_options or wp_posts. The only route upward is cwp db push (§13, F-023): typed environment-name confirmation, mandatory backup, --force on a protected environment, and it carries the whole local database along with it. That path is deliberately awkward because it is for data. A structural change must not need it.
  • Defined in the site plugin, it travels with cwp push / cwp deploy, it is in git, it is reviewable, and it reproduces on a fresh environment.

The general rule underneath: a post type is structure, not content. Anything defined in the database is content by this tool’s definition and therefore downward-only. Defining structure there puts it in the content channel and erodes the separation the whole tool rests on.

.cwp.yml is likewise the wrong home. It configures cwp, not the site. A post_types: block there would mean cwp generates PHP or re-registers types at deploy time — which makes it a half-framework rather than middleware (see CLAUDE.md). The only post-type key in .cwp.yml is pull.truncate_post_types, and that describes log data, not structure.

Consequences for the tool:

  • Generating the registration code is out of scope. WP-CLI already does it (wp scaffold post-type <slug> --plugin=<slug>), reachable through cwp wp (§5.6).
  • Reading post types from the database is for diagnosis only, never as a source of truth. That is the F-025 drift check in cwp doctor, and the ordering check in §9.

7. Uploads strategies

Mode Behaviour
sync Full copy of the remote uploads directory (tarball transport, see §5.4). Correct, slow, large.
skip Nothing. Broken images locally.
proxy Default. No transfer. Any file below /wp-content/uploads/ that is absent locally is served from the remote.

Rationale: on an image-heavy Bricks site this turns a twenty-minute first-time setup into a two-minute one, and keeps every subsequent cwp pull cheap.

7.1 Where the fallback runs — the nginx snippet (F-032)

proxy is a web-server fallback, not a WordPress filter. .ddev/nginx/cwp-uploads-fallback.conf matches location ^~ /wp-content/uploads/ with try_files $uri @cwp-uploads-remote, and that named location proxies to the environment’s URL. It is written by cwp init and rewritten by cwp pull, is committed rather than gitignored — a clone must serve uploads the same way without cwp installed (§2, principle 3) — and takes effect only after ddev restart.

^~ is load-bearing: a regex location wins over a plain prefix match, and DDEV’s WordPress configuration matches static assets by extension, so without it the fallback would apply to everything except the image and font requests it exists for. The proxied request carries the remote Host header, sends no cookies upward and drops any Set-Cookie coming back: it fetches files, it does not share a session.

Why not a mu-plugin. That was the original design and it was wrong (B-019). A PHP filter can only rewrite URLs WordPress itself emits — the attachment API. A .woff2 requested from an @font-face rule, Bricks’ generated CSS below uploads/bricks/, a background image stored as a URL string in element settings and a literal <img src> in post content all reach the browser without passing through it, so they 404 and the symptom reads as a styling defect. nginx has done fallback proxying for twenty years; the mu-plugin was cwp rebuilding it inside WordPress, where it cannot reach a request PHP never sees.

The mu-plugin remains, as the narrower fallback, for webserver_type: apache-fpm and for a project whose .ddev/ cwp did not scaffold. It is chosen only when .ddev/config.yaml does not state nginx-fpm — DDEV’s own default is nginx, but a config that does not say so is one cwp cannot vouch for. Whenever it is chosen, cwp pull says so and names the coverage gap: a silent downgrade to partial coverage is B-019 with a new mechanism.

cwp doctor checks the mechanism, not an inventory. One request for an uploads path the database knows and the filesystem does not, expecting 200. “There are attachments whose files are missing” is the right question under sync and skip and pure noise under proxy, where almost every file is missing by design — which is why the gap stayed invisible. Under the mu-plugin no request is made at all: it rewrites URLs in rendered output, so a direct request for the file 404s even when it is working as designed.

A remote behind HTTP authentication breaks the fallback. So did the mu-plugin, so this is no regression — but it is invisible either way, which is what the check is for.

7.2 Privacy caveat (relates to §8)

proxy keeps the local copy coupled to production, and since F-032 it is coupled more widely than before: the mu-plugin fetched production files for images WordPress rendered, while the snippet serves everything below uploads/ on demand — including files no WordPress code path would ever have requested, such as a PDF whose URL is guessed or an orphaned export somebody left in the media library.

This is normally fine — uploaded media is public, and it is the same server serving the same file to anyone who asks. But “scrubbed” does not mean “disconnected from production” while proxy is active, and the local site is now a complete read-through of the remote uploads directory. Use sync (or skip) when full separation is required; both remove the fallback rather than leaving it in place.


8. Scrub pipeline

Runs after every pull unless disabled. This is a GDPR requirement, not a convenience: production databases contain personal data, and pulling one onto a laptop is a processing operation.

  1. Neutralise outbound mail. Write mu-plugins/cwp-mail-guard.php which short-circuits pre_wp_mail and logs instead of sending. This must run even if scrub: false, because SNN-BRX ships its own SMTP feature that bypasses DDEV’s Mailpit.

  2. Anonymise users, except the site’s own staff: users holding one of pull.keep_roles (default administrator, editor), super admins, and the address in pull.keep_admin_email. Everyone else — subscribers, customers, visitors — gets user_email replaced with user-<ID>@example.invalid, blank display names and a reset password. Preserve uniqueness constraints.

    Keeping staff accounts is a deliberate trade-off: they are what you log in with locally, and they are not the bulk personal data the scrub exists to remove. Narrow pull.keep_roles to [] when even that is too much. Sessions are destroyed for every user, staff included.

  3. Truncate log and submission tablespull.truncate_tables, defaulting to Bricks form submissions. Detect table existence first; never fail on absence.

  4. Delete log postspull.truncate_post_types, defaulting to the SNN-BRX 404, redirect, activity and mail logs.

    These are not tables — corrected 2026-07-28 (B-001). Every SNN-BRX logging feature registers a custom post type and writes entries to wp_posts; the earlier defaults (snn_301_logs, snn_404_logs, snn_search_logs) were table names that never matched anything, so the scrub left IP addresses, user agents and mail bodies in place while reporting success. The real types are snn_404_logs, snn_redirect_logs, snn_activity_log and snn_mail_logs.

    snn_301_redirects is the redirect rules type — site configuration, not a log — and must never be deleted, despite sitting one word away from snn_redirect_logs in the same feature file.

    Implemented as a generated script run through wp eval-file, like the user anonymisation: a busy site accumulates tens of thousands of 404 rows, which rules out passing IDs as arguments. Deletion uses wp_delete_post($id, true) so postmeta and term relationships go too, and so nothing merely lands in the trash. Rows are found by querying wp_posts rather than post_type_exists(), because the feature may be switched off while its rows remain.

    Two more types added 2026-07-29 (B-011): snn_search_logs and snn-agent-history. The first is the tail of B-001 — it was correctly removed as a phantom table name and never re-added in its real form as a post type, so the one name that changed category fell through that fix. The second stores AI chat transcripts and postdates B-001 entirely.

    Failure is fatal, like step 2 and unlike the cosmetic steps that follow.

  5. Delete the terms of pull.truncate_taxonomies — defaulting to snn_ip_address and snn_user_agent.

    Deleting the posts is not sufficient, and assuming otherwise cost a whole release (B-011). SNN-BRX’s search logger stores the visitor IP and user agent as taxonomy terms, not postmeta. wp_delete_post($id, true) removes term relationships and leaves the terms in wp_terms, so the scrub deleted every search log, reported success, and left a browsable list of every visitor’s IP address behind. Verified by experiment on a live install rather than reasoned about: post gone, postmeta gone, term still there.

    The whole taxonomy is emptied, not only the terms that just lost a relationship — these taxonomies exist solely to index the logs, so a term surviving from an earlier purge is the same IP address either way. Terms are found by querying term_taxonomy and deleted with wp_delete_term(), which takes the term relationships and termmeta with them; the taxonomy is registered as a private placeholder first when the feature that owns it is switched off, since wp_delete_term() needs it registered and the rows are there regardless.

    A declared list, not one inferred from the scrubbed post types: a switched-off feature registers no taxonomies, so there would be nothing to discover them from — the same reason step 4 queries the database instead of trusting post_type_exists().

    snn_search_count is deliberately excluded. It hangs off the same posts, but it is a count rather than personal data — the same line snn_301_redirects sits on the other side of.

    Failure is fatal, for the same reason as step 4.

  6. Delete transients and expired sessions.

  7. Set blog_public = 0.

  8. Report what was scrubbed in the command output.

The scrub step is implemented as a discrete, separately invocable command (cwp scrub) so it can be re-run and tested in isolation.

What the scrub does not disconnect (§7.2). Under the default pull.uploads: proxy the local site reads through to the remote for every file below uploads/ — not only the images WordPress renders, since F-032 moved the fallback to the web server. A scrubbed database and a proxied uploads directory together are a local copy with production personal data removed from the database and production files still one request away. That is a deliberate trade and usually the right one, because uploaded media is served publicly by the same host anyway; where it is not, pull.uploads: sync or skip is the setting that severs the connection, and both remove the fallback rather than leaving it in place.


9. Bricks post-import steps

Applied after a pull when bricks.enabled:

  1. Regenerate code signatures. Bricks signs code elements per site; imported content is blocked until re-signed. Verified 2026-07-24: the Bricks CLI docs (Bricks 1.8.1+) document no WP-CLI command for code signatures, and an open forum feature request confirms it does not yet exist. cwp therefore capability-checks first — parse ddev wp bricks --help (equivalently ddev wp help bricks) for a signature subcommand and use it if present — and otherwise degrades to a clear instruction: regenerate under Bricks → Settings → Custom Code → Code signatures. Never hardcode an unverified command name.
  2. Regenerate CSS files if the site uses external CSS file generation. Verified 2026-07-24: the command is wp bricks regenerate_assets (Bricks 1.8.1+, requires the CSS loading method set to external files). Guard it behind the same capability check and skip with a warning when the subcommand is absent or CSS is inlined.
  3. Report license state — Bricks is licensed per site; the local install may need its own activation. Report, do not attempt to fix.

Each step is best-effort: a failure produces a warning, not an aborted pull. Sources: https://academy.bricksbuilder.io/article/bricks-cli/ and the Bricks community forum feature request for code signing via WP-CLI.

Bricks 2.4 does not change these three steps — there is still no code signature command among its 145 abilities (verified 2026-07-29 by enumeration, see §9.2) — but it adds two constants that address step 1 and step 3 better than a post-import step can:

  • BRICKS_DANGEROUSLY_AUTO_SIGN_CODE_ON_BUILDER_SAVE and ..._ON_BUILDER_RERENDER remove the re-signing chore on a local environment. The name is a warning and cwp treats it as one: these belong in a DDEV wp-config.php and cwp must never write them to a production one.
  • define( 'BRICKS_LICENSE_KEY', … ) makes licensing a deployment concern rather than a per-install click, which is what step 3 could only ever report on.

9.2 Bricks 2.4 abilities — the integration surface

Verified 2026-07-29 against a running Bricks 2.4-beta2 on WordPress 7.0.2 with WP-CLI 2.12.0, by enumerating the live registry and reading the theme source shipped beside it — not from the release notes.

Bricks 2.4 registers 145 abilities in the bricks/ namespace on WordPress core’s wp_abilities_api_init hook (includes/abilities/manager.php:142). The Abilities API is core’s, present since WordPress 6.9. Two consequences decide how cwp integrates:

The registry holds more than Bricks’ 145 — re-counted 2026-07-31 on the same site: snn/* from the child theme (24), core/* (3) and mcp-adapter/* (3), for 175 in total. Both numbers matter and neither replaces the other: 145 is the Bricks integration surface this section specifies, 175 is what a call can reach. cwp bricks ability (F-030) therefore does not filter by namespace.

Every ability declares its own disposition. meta.annotations carries readonly, destructive and idempotentbricks/get-page-elements is readonly: true, bricks/set-page-elements is destructive: true. That is what lets a general-purpose call command apply the §5.4 guard set per call instead of guarding everything or nothing.

The annotations are a claim, not a guarantee, and 46 of the 175 make none at all (every snn/* among them). An ability that declares nothing is treated as a write: guarding a read costs a confirmation, and letting an undeclared write through to production costs what this tool exists to prevent. Null is therefore kept distinct from false everywhere it is read — Bricks writes readonly: null on a setter rather than false, so collapsing the two would make silence look like a declared read.

  1. The MCP Adapter plugin is not required. It exposes abilities over HTTP for AI clients; the abilities exist and are callable without it. cwp therefore reaches the entire Bricks surface through ddev wp and cloudron exec -- wp, with nothing installed beyond Bricks itself.
  2. wp ability must not be depended on. The CLI command lives in the separate wp-cli/ability-command package and is absent from WP-CLI 2.12.0 on both the DDEV container and the Cloudron app. cwp invokes abilities through wp eval-file with a generated shim — the transport the scrub already uses — which needs no per-environment install and behaves identically on both sides.
  3. The shim runs as a user, not as nobody. See §9.3; this is the one part of the transport that does not carry over from the scrub.

The enable chain, in evaluation order (manager.php:349):

gate location note
BRICKS_DISABLE_MCP wp-config.php hard off; no force-on constant exists by design
abilitiesApi bricks_global_settings the admin toggle, Bricks → Settings → AI
enabled + deny-list bricks_mcp_settings per-ability, absent option reads as enabled

bricks/start-here, bricks/get-mcp-version and bricks/list-ability-status are always on regardless of the third gate (Manager::ALWAYS_ON_ABILITIES).

They are not always on regardless of the first two, and an earlier draft of this paragraph said they were. Manager::register() returns before it hooks wp_abilities_api_init when is_enabled() is false (manager.php:136), and returns earlier still when the abilitiesApi key is merely absent from bricks_global_settings (:121) — the state of any site whose AI tab has never been saved. So with the constant set, or the toggle off, or the tab untouched, nothing is registered at all, the always-on three included.

ALWAYS_ON_ABILITIES exempts an ability from the per-ability deny-list, which is evaluated after registration. It says nothing about whether registration happened. Reading it as “a capability check can always get an answer” produced B-014: a Bricks 2.4 site was told to update Bricks, because “the ability is missing” collapses four causes into one wrong one.

The diagnostic path therefore does not go through an ability at all — see §9.5 — and §9.3 is a fourth gate beyond these three.

Ability names and error codes are append-only by Bricks’ own stated policy; renames go through deprecation aliases for at least one minor version. That is a strong enough contract to build on, and cwp still capability-checks rather than assuming, exactly as it does for wp bricks regenerate_assets.

The transfer package

Four abilities carry the global import/export: bricks/list-transfer-items, bricks/export-transfer-package, bricks/inspect-transfer-package, bricks/import-transfer-package. Twelve transfer types:

group types
style classes, variables, theme-styles, color-palettes, custom-fonts, icon-manager
structure templates, components, global-queries, breakpoints (singleton)
settings settings, custom-capabilities

The exported ZIP is already a tree of JSON files (styles/classes/classes.json, structure/templates/template-*.json, manifest.json, …), so cwp extracts it into bricks.export_dir and needs no format of its own. A re-zipped tree is accepted by inspect-transfer-package even though its bytes differ, because expectedZipHash is a consistency check between inspect and import rather than a signature — verified by round-tripping this project’s site. The extracted tree is the source of truth; the ZIP is transient.

Constraints that belong in the argument builders, not in a comment: the package is capped at 32 MB (MCP_MAX_ZIP_BYTES), the manifest schema is bricks/unified-global-transfer version 1, replace requires allowOverwrite, and the api-keys and custom-code settings tabs require allowSensitive.

Note that bricks/list-cms-sources concerns custom field sources (ACF, JetEngine, Meta Box, CMB2, Pods, Toolset, WooCommerce), not post-type registration. The name reads the other way, so it is recorded here: §6.2 stands unchanged.

cwp bricks export / cwp bricks import round-trip templates and components through bricks/ as JSON. Design note for later: always mutate exported JSON, never author it from scratch — Bricks JSON carries generated element IDs and references to global class IDs.

9.3 The ability transport runs as a WordPress user

Verified 2026-07-29, and it corrects the transport §9.2 first specified. Every bricks/* ability carries a permission callback that tests the current user. WP-CLI has no logged-in user, so wp eval-file executes as user 0 and every ability refuses:

WP_ERROR: ability_invalid_permissions
Notice: Current user lacks the "read" builder permission
  | data: {"code":"bricks_forbidden_builder_permission","permission":"read"}

That includes bricks/list-ability-status. ALWAYS_ON_ABILITIES exempts an ability from the deny-list, not from the permission callback — the two are independent gates, and reading the first as covering the second is what made the capability check look free. It is still cheap; it is not unauthenticated.

The fix is WP-CLI’s own global flag: wp --user=<id|login> eval-file …, which sets the current user before WordPress dispatches. With it, all three always-on probes return clean JSON on both conduits.

The shim is piped, not written. wp eval-file - reads the script from STDIN (verified 2026-07-29), so unlike the scrub — which writes to .cwp/tmp and deletes afterwards — the ability transport puts no file on either filesystem. That matters more remotely than locally: a generated PHP file inside a live Cloudron container is a thing that can be left behind by an interrupted run.

Its output is framed by a sentinel line rather than found by scanning for a leading {. WordPress emits _doing_it_wrong notices as HTML on stdout, and the scrub’s reverse-scan heuristic (parsePostTypeScrubReport) would be reading a brace out of markup sooner or later.

Which user is resolved, in order:

  1. bricks.wp_user in .cwp.yml, when set — an id, login or email, passed through untouched, because those are exactly what --user accepts.
  2. Otherwise the first administrator, read at runtime with wp user list --role=administrator --field=ID --number=1.

Auto-detection is the default because a one-admin site is the common case, and because requiring configuration before a read-only check will run is a poor trade. The override exists because “the first administrator” is an arbitrary choice on a site with several, and because a dedicated service account is the right answer on a production environment.

A site with no administrator is a failure with its own fix hint, not a silent fallback to user 0: running as nobody produces the permission error above, which names a builder permission and says nothing about the actual cause.

The user is never cached. capabilities.wp_allow_root is cached because it is a property of the Cloudron image, which does not change between runs. Which users exist on a site is database state, and cwp pull replaces it wholesale.

This applies to every ability call — F-020, F-025, F-027 and F-028 alike — so it lives in the transport, not in any one command.

9.4 Application passwords cannot be re-read

cwp bricks mcp (F-027) mints its own credential through wp user application-password create <user> <name> --porcelain, which is present in WP-CLI 2.12.0 on both sides. The plaintext exists exactly once: core stores wp_hash_password() output (class-wp-application-passwords.php:105), so application-password list returns the hash and no command can hand the secret back.

That makes the idempotence F-027 originally assumed impossible. exists can report that a password is present; it cannot let a second run emit a working config. So:

  • when no password named for this project and environment exists, cwp creates one and prints the config;
  • when one exists, cwp refuses, with the fix: pass --rotate to delete and recreate it.

Refusing rather than rotating is the safe default because rotation is invisibly destructive — every other client already configured against that password stops working, and nothing about a repeated cwp bricks mcp says “invalidate my other machines”.

The secret never enters the repository: it is printed for the user to place, or handed to the client’s own CLI. Both .cwp.yml and bricks/ are committed, so neither may ever hold it.

9.5 The readiness probe calls no ability

Added 2026-07-29 after B-014. Diagnosing the chain in §9.2 by calling an ability is circular: the abilities are exactly what is missing in four of the cases the diagnosis has to tell apart.

cause what the ability call reports the actual fix
WordPress < 6.9 no wp_get_ability update WordPress
Bricks < 2.4, or inactive ability not registered update or activate the theme
BRICKS_DISABLE_MCP set ability not registered nothing — this is intended
AI tab never saved ability not registered Bricks → Settings → AI
toggle switched off ability not registered Bricks → Settings → AI
ability denied individually ability not registered the deny-list

Three of those rows produce the identical symptom and have different fixes, and the constant’s row must not be reported as a fault at all.

So cwp doctor reads the facts directly, through a shim (templates/bricks-state.php) that touches nothing but the theme, core and the options table: the Bricks parent-theme version and whether it is active, the WordPress version, whether wp_get_ability exists, abilitiesApi as three states (true / false / never saved), bricks_mcp_settings.enabled, BRICKS_DISABLE_MCP, and postTypes.

Two consequences:

  • It needs no user, because it calls no ability — and the flag must be absent, not --user=0, which WP-CLI rejects with “Invalid user ID, email or login: ‘0’”. Verified against WP-CLI 2.12.0.
  • Reading bricks_global_settings with get_option() is allowed here and nowhere else. Settings otherwise go through bricks/get-global-settings (§9.1); the diagnostic path cannot go through the thing whose absence it is diagnosing.

An ability is called afterwards, once the facts say one should answer. It confirms; it is no longer the evidence.

9.1 Bricks and custom post types

Bricks does not define post types; it references them, in three places:

  1. Template conditions — which post type a template applies to.

  2. Query loops — which post type a loop queries.

  3. The builder-enabled post types — a Bricks global setting, stored in the database and therefore pulled downward with everything else. That is correct here: it is a per-site setting, not structure.

    Verified 2026-07-29: the key is postTypes inside the bricks_global_settings option, read off this project’s site where it holds ["page"]. Bricks 2.4 exposes it through bricks/get-global-settings and bricks/set-global-settings, which is how cwp reads and writes it (F-027) — a read-modify-write of that one key, never a whole-option overwrite, because the same option carries customCss, maintenance mode and the builder configuration.

    This setting is the reason registering a custom post type is not enough to work on one. The builder will not open for a post type absent from that list, and because the value is database state it travels downward only: enabling it locally never reaches production. F-025 reports the gap per environment.

The first two create an ordering dependency for cwp bricks import (F-020): a template whose condition names a post type the site plugin has not registered imports cleanly and then matches nothing. Silent failure is the one outcome this tool does not accept, so import compares the post-type slugs referenced by the JSON against wp post-type list on the target and warns per unknown slug, with the fix — deploy the site plugin first, then re-import. A warning, not an abort: a template may legitimately be inactive.

Registration itself stays in the site plugin, per §6.2.


10. DDEV provider generation

DDEV supports custom providers at .ddev/providers/<name>.yaml. cwp init generates .ddev/providers/cloudron.yaml so that ddev pull cloudron works natively. The provider is a convenience and a portability guarantee, not the primary path — cwp pull remains the richer command because it also runs scrub and Bricks steps.

Verified 2026-07-24 against the DDEV provider docs and the shipped provider examples. The schema is a set of top-level stanzas:

Stanza Role
environment_variables Plain key: value map, exported into the web container for use by the command stanzas. Not a command block.
auth_command Authenticate against the host.
db_pull_command Must produce /var/www/html/.ddev/.downloads/db.sql.gz.
db_import_command Optional. Defaults to importing that gzip into the db database.
files_pull_command Must place user files under /var/www/html/.ddev/.downloads/files.
files_import_command Optional. Defaults to copying into the upload dirs.
db_push_command Ships /var/www/html/.ddev/.downloads/db.sql.gz upstream.
files_push_command Copies $DDEV_FILES_DIRS upstream.

Each command stanza is a mapping with a command: key holding a literal block scalar (|) and an optional service: key (defaults to web). The script runs inside the named container; the standard preamble is set -eu -o pipefail. $DDEV_FILES_DIRS (comma-separated upload dirs) and the environment_variables entries are available inside the scripts.

service: host is mandatory here, and the default is what broke it — corrected 2026-07-29 (B-015). The Cloudron CLI is installed on the developer’s machine, not in the web container:

$ ddev exec "which cloudron || echo NOT PRESENT"
NOT PRESENT

So every stanza in cwp’s provider pins service: host, and the paths are relative to the project root rather than /var/www/html. The version of this section written on 2026-07-24 was verified against the schema and never executed, which is why every stanza name was correct and the provider could not run at all.

environment_variables:
  app: example.com          # Cloudron --app value

db_pull_command:
  service: host
  command: |
    set -eu -o pipefail
    mkdir -p .ddev/.downloads
    cloudron exec --app "${app}" -- wp db export --allow-root - \
      | gzip > .ddev/.downloads/db.sql.gz

Two further rules the generated provider follows:

  • The files pull does not use cloudron sync pull, which is a silent no-op (B-003). It uses the same remote-tar-then-cloudron pull workaround as pullDirectoryTree.
  • Both push stanzas refuse. ddev push has no confirmation, no backup, no protected-environment check and no typed environment name, and a provider cannot add them. They exit 1 naming cwp db push and cwp deploy. Deleting the stanzas instead would produce DDEV’s own error, which explains nothing — and §2’s invariant is that the upward path is hard to take by accident, not that it is undefined.

ddev pull is not cwp pull. It brings production data down unscrubbed, installs no mail guard, and takes no pre-pull snapshot. The provider says so in its header; it is a portability guarantee, not a substitute.

Source: https://docs.ddev.com/en/stable/users/providers/ and the provider examples DDEV ships into .ddev/providers/ (git.yaml.example, localfile.yaml.example, rsync.yaml.example), read on 2026-07-29 — the service: host usage came from those rather than from the prose.


11. Hooks

Executable shell scripts in .cwp/hooks/, run with the project root as CWD and a documented set of environment variables (CWP_ENV, CWP_PROJECT, CWP_LOCAL_URL, CWP_REMOTE_URL, CWP_REMOTE_APP, CWP_WP_CONTENT).

  • post-pull.sh
  • pre-push.sh (non-zero exit aborts the push)
  • post-deploy.sh

Generated as commented no-op samples.


12. Output contract

  • Human output → stderr, with a spinner per step.
  • --json output → stdout, a single JSON object: { "ok": bool, "command": str, "env": str, "steps": [...], "warnings": [...], "error": {...}|null }
  • --verbose echoes every subprocess command before running it.
  • --dry-run on every mutating command prints the plan and exits 0.
  • cwp wp is the one documented exception to this contract — see §5.6.
  • --json implies non-interactive. A command that would otherwise prompt — e.g. the cwp pull “local database will be replaced” guard — must be given --yes up front. Without it, the guard refuses with exit code 6 (aborted) rather than blocking on a prompt that no machine caller can answer. This keeps cwp safe to drive from Claude Code and CI.

Exit codes

Code Meaning
0 success
1 generic error
2 configuration error
3 missing dependency
4 remote / Cloudron error
5 refused by a safety guard
6 aborted by user

13. Scope

Shipped through 0.3.0: init, doctor, pull, fetch, push, deploy, db snapshot|restore|list|rm|export|import, wp, shell, logs, open, status, scrub, hooks, JSON output, README + docs.

Shipped in 0.4.0: bricks export|import, bricks mcp, bricks post-types, bricks abilities|ability, plugins snapshot|apply, projects, config get|set, content list|status|pull|push, DDEV provider hardening, plus the custom-post-type drift and Bricks 2.4 readiness checks in doctor.

Later: db push — deferred, since the content transfer answers the case it was really being asked for; multi-env matrix operations, remote template sync between environments, shell completion.