Scaffold a local WordPress + AI-agent development environment (Docker Compose) — WordPress + MariaDB plus an isolated workspace container with Node, Claude Code, the Cursor CLI, PHP, and WP-CLI ready to go.
It scaffolds the project and runs the initial setup for you — docker compose up, then installs WordPress and the configured plugins — so you land on a working site. Pass --scaffold-only to just write files and skip Docker. The generated project ships npm scripts (npm run start, npm run bash, npm run claude, npm run cursor, …) for everyday use.
# npm create form (the create- prefix enables this):
npm create wp-local-dev-agent-sandbox@latest my-site
# or directly with npx:
npx create-wp-local-dev-agent-sandbox my-site
# choose a host port (default 8080):
npx create-wp-local-dev-agent-sandbox my-site --port=8090
# run a setup script in the workspace, add wp-config constants, and activate
# plugins (in order) it drops into wp-content — see "Customizing setup" below:
npx create-wp-local-dev-agent-sandbox my-site \
--setup-script=./setup.sh \
--defines=./defines.json \
--activate=oxygen-elements,breakdance-elements,breakdance-main
# just write files, don't touch Docker:
npx create-wp-local-dev-agent-sandbox my-site --scaffold-onlyThrough
npm create, put the flags after--, e.g.npm create wp-local-dev-agent-sandbox@latest my-site -- --port=8090.
Docker must be running. When it finishes you have a live site at http://localhost:8080 — log in at /wp-admin with admin / password (the default; configurable in .env via WP_ADMIN_USER / WP_ADMIN_PASSWORD). Then:
cd my-site
npm run start # bring the stack up next time (it stays up otherwise)
npm run bash # shell into the workspace container
npm run claude # launch Claude Code in the workspace
npm run cursor # launch the Cursor CLI agent in the workspaceClaude auto-login (same as agent-sandbox): mint a token once on your host with claude setup-token and save it to ~/.agent-sandbox/oauth-token (or export CLAUDE_CODE_OAUTH_TOKEN=<token>). npm run claude resolves the token from either source and forwards it into the workspace by name (so the value never appears on the command line), and the workspace's entrypoint pre-clears Claude's three first-run gates (login picker, --dangerously-skip-permissions warning, trust-folder dialog) — so Claude lands straight at the prompt, logged in, no /login. No token found? Claude just starts and you /login once; it persists in workspace/ across rebuilds.
Cursor auto-login: the same flow with a Cursor API key — generate one in the Cursor dashboard (Settings → API Keys) and save it to ~/.agent-sandbox/cursor-api-key (or export CURSOR_API_KEY=<key>). npm run cursor resolves and forwards it by name, then launches with --force --approve-mcps so the agent runs commands and uses the sandbox's MCP servers without prompting. No key found? Cursor starts unauthenticated and you can cursor-agent login once; it persists in workspace/.
my-site/
├── docker-compose.yml # db + wordpress + workspace + playwright services
├── docker-compose.override.yml # only if a dev script was set — adds the long-running `dev` service
├── workspace.Dockerfile # Node + Claude Code + Cursor CLI + PHP + WP-CLI (runs as non-root)
├── .env # DB creds + WP_PORT
├── .gitignore # ignores the bind-mounted data dirs
├── package.json # the npm-scripts UX (setup/start/stop/bash/claude/cursor/wp/reset)
├── sandbox.config.json # plugins to install, wp-config defines, setup/dev scripts & activation order for `npm run setup`
├── php/php.ini # custom PHP overrides for the wordpress container (upload limits, etc.)
├── scripts/ # provisioning steps run by initial-setup.sh (install-wp, defines, user setup script, plugins, agent-connector, mcp, skills) + in-workspace.sh (credential-resolving launcher for bash/claude/cursor)
├── bin/ # cursor-wp-mcp-helper — Node CLI for the WordPress MCP server, baked onto the workspace PATH
├── skills/ # agent skills installed into the workspace (wordpress-dev, cursor-wp-mcp-helper) — copied to both ~/.claude/skills and ~/.cursor/skills
└── README.md
WordPress data, the database, and the workspace home are bind-mounted into wp/, db/, and workspace/ in the project, so everything is visible on your machine and survives restarts.
Beyond the bundled plugins, you can run a one-time setup script, add wp-config.php constants, activate plugins in a chosen order, and keep a long-running dev script (a watcher, say) alive alongside the stack. These are flags on the create command, persisted into the project's sandbox.config.json (and, for the dev service, a generated docker-compose.override.yml), so they re-apply on npm run setup / npm run reset — the project stays self-contained.
npx create-wp-local-dev-agent-sandbox my-site \
--port=8090 \
--setup-script=./setup.sh \
--dev-script=./dev.sh \
--defines=./defines.json \
--activate=oxygen-elements,breakdance-elements,breakdance-mainOn the first npm run setup the one-time steps run in order: install WordPress → apply --defines → run --setup-script → install bundled plugins and activate the --activate list. So a plugin your script drops into wp-content exists by the time it's activated. The --dev-script runs separately and continuously (see below).
-
--setup-script=PATH— a shell script run inside the workspace container asnode— the same environmentnpm run bashgives you, with the working directory at/home/nodeand WordPress at/home/node/wp. Use it to clone a repo and run its installer, build a plugin/theme, seed content, etc. It's piped in over stdin, sogh repo clone <repo>lands a checkout right next to./wp.npm run setupmay run it again, so guard side effects (e.g. skip a clone when the directory already exists). To clone a private repo, authenticateghonce inside (npm run bash→gh auth login; it persists inworkspace/), or exportGH_TOKENon your host before setup — it's forwarded into the container. -
--dev-script=PATH(or--dev-command="…"for a one-liner) — a shell script that runs in its own long-runningdevcontainer for as long as the stack is up — e.g.cd /home/node/my-plugin && npm run watch. It reuses the workspace image, so it runs asnodewith the same/home/nodemount (a checkout your setup script cloned at/home/node/<repo>is visible to it). It's supervised: started bynpm run start, stopped bynpm run stop, and restarted if it exits — so a crashed watcher, or one whose target directory isn't there yet (setup still running), self-heals. Follow it withnpm run dev:logs. Adding it generates adocker-compose.override.yml(auto-merged by Compose) andscripts/dev.sh. -
--defines=PATH— a JSON file of{ "WP_CONST": value }pairs written intowp-config.phpas constants viawp config set, which places them correctly (above the "stop editing" marker) and updates them in place on re-run. Booleans and numbers become raw PHP literals (define( 'WP_DEBUG', true )); strings are quoted (define( 'WP_MEMORY_LIMIT', '512M' )). Use{ "value": "...", "raw": true }to force a raw (unquoted) value.{ "WP_DEBUG": true, "WP_MEMORY_LIMIT": "512M" }Why key:value rather than a raw
wp-configsnippet? You don't have to worry about where in the file eachdefine()lands or about duplicating one that already exists —wp config sethandles placement and is idempotent. -
--activate=a,b,c— plugin slugs to activate, in this exact order, after the setup script. This is for plugins that are already present (e.g. dropped intowp-contentby your script) — there's nothing to download, just activate. For plugins installed from wordpress.org or a.zip, usepluginsinsandbox.config.json(see below) instead.
A worked example (Breakdance) lives in examples/.
The flags above just write into this file; you can also edit it directly and npm run reset:
{
"plugins": [
"ai",
{ "source": "akismet", "activate": false, "version": "5.3" },
{ "source": "https://example.com/plugin.zip", "activate": true }
],
"defines": { "WP_DEBUG": true, "WP_MEMORY_LIMIT": "512M" },
"setupScript": "scripts/user-setup.sh",
"devScript": "scripts/dev.sh",
"activate": ["oxygen-elements", "breakdance-elements", "breakdance-main"]
}plugins— installed (and activated unless"activate": false) from a wordpress.org slug or a URL/path to a.zip.versionis optional (slugs only).defines—wp-config.phpconstants (see--definesabove).setupScript— project-relative path to the one-time script run in the workspace (--setup-scriptcopies your file here asscripts/user-setup.sh).devScript— project-relative path to the long-running dev script (--dev-script/--dev-commandwrites it toscripts/dev.shand adds thedevservice viadocker-compose.override.yml). Editscripts/dev.shandnpm run restartto change what it runs.activate— slugs activated in order, aftersetupScript(see--activateabove).
- Node.js >= 18 (to run the CLI and the project's npm scripts)
- Docker with Compose v2 (to actually run the environment)
Set defaults once and they apply to every project you scaffold (and every environment the devbox server creates) — at ~/.config/create-wp-local-dev-agent-sandbox/config.json (or $XDG_CONFIG_HOME/...):
{
"wpAdminUser": "admin",
"wpAdminPassword": "change-me",
"wpAdminEmail": "you@example.com"
}At scaffold time these seed the new project's .env (WP_ADMIN_USER / WP_ADMIN_PASSWORD / WP_ADMIN_EMAIL); with no config file they fall back to admin / password. To override for a single project, edit that project's .env and npm run reset. (Keep the password free of shell metacharacters, or quote it in .env — the setup scripts source .env.)
This package is also a library. If you ship a WordPress plugin (or a stack of them), you can publish your own create-<brand> command that scaffolds this same sandbox with your plugins pre-installed — no fork, you just depend on this package.
-
Create a package named
create-<brand>and add this one as a dependency:mkdir create-oxygen-wp && cd create-oxygen-wp npm init -y npm install create-wp-local-dev-agent-sandbox
-
Point its
binat a one-file script that callscreate()with a preset. A preset adds plugins — each entry is a wordpress.org slug, or{ source, activate?, version? }wheresourceis a slug or a URL/path to a.zip(the same format the generated project'ssandbox.config.jsonuses):#!/usr/bin/env node import { create } from 'create-wp-local-dev-agent-sandbox'; create({ preset: { name: 'oxygen-wp', // so messages read `npm create oxygen-wp` plugins: [ { source: 'https://example.com/oxygen.zip', activate: true }, ], }, });
{ "name": "create-oxygen-wp", "type": "module", "bin": { "create-oxygen-wp": "index.js" }, "dependencies": { "create-wp-local-dev-agent-sandbox": "^0.3.0" } } -
Publish it. Now anyone can run:
npm create oxygen-wp my-site
They get the full sandbox (WordPress + Claude Code + Cursor CLI + the WordPress & Playwright MCP servers + Root for Agents) plus your plugins, installed and activated on the first
npm run setup.
Your preset's plugins are appended to the defaults, so mcp-adapter and root-for-agents are always present. Everything else — templates, Docker setup, the npm run … UX — is inherited from this package, so improvements here flow to every create-<brand> that depends on it.
A preset can also carry the same customizations as the CLI flags above — they're merged into the generated sandbox.config.json (and combine with anything the end user passes):
create({
preset: {
name: 'breakdance-wp',
plugins: [{ source: 'https://example.com/breakdance.zip', activate: true }],
defines: { WP_DEBUG: true, WP_MEMORY_LIMIT: '512M' },
activate: ['oxygen-elements', 'breakdance-elements', 'breakdance-main'],
setupScript: 'set -euo pipefail\ncd /home/node\n# …clone/build/seed here…\n',
devScript: 'cd /home/node/breakdance && npm run dev\n',
},
});setupScript / devScript here are the scripts' contents (strings), written into the project as scripts/user-setup.sh / scripts/dev.sh. A user's --setup-script=PATH / --dev-script=PATH overrides them.
Premium plugins: a public
create-<brand>can only bake in a.zipURL that's publicly reachable. For licensed plugins, point at a gated endpoint you control, or have your wrapper read the URL from a prompt or an env var instead of hardcoding it.
Working on the scaffolder itself, or cutting a release? See CONTRIBUTING.md.
GPL-2.0-or-later