Authoring project commands
Your project’s README keeps accumulating copy-paste command snippets: “run this to seed the DB”, “run this to rebuild the front-end”, “run this when the queue is misbehaving”. This guide walks through replacing those snippets with first-class dwe <id> commands the whole team can discover via dwe commands, run with dwe cmd <id>, and compose into larger workflows.
The full schema lives in ../reference/config/commands/index.md; this page covers the three types you will reach for most often (shell, service_exec, workflow) and the directives that make them safe to ship.
File layout and command IDs
Section titled “File layout and command IDs”Commands live under workspace/commands/. The directory path becomes the command ID, dot-separated. The file’s basename is the leaf segment, and each key inside the file’s commands: map adds another dot.
workspace/commands/├── db.yml # group: db└── db/ └── seed.yml # group: db.seedA command keyed default inside workspace/commands/db/seed.yml would be dwe cmd db.seed.default. More commonly, you put the core action under a descriptive key:
group: title: Database seeding
commands: run: # full ID: db.seed.run type: shell description: Seed the database with development fixtures cmd: | "$DWE_BIN" shell app -c "php artisan db:seed --class=DevSeeder"Run it with dwe cmd db.seed.run, see it under dwe commands list, or open the interactive browser with bare dwe commands.
Rule of thumb: put the core commands of a group in one file named after the group, split into a sub-directory only when there are enough commands to warrant logical sub-groups.
type: shell — the simplest case
Section titled “type: shell — the simplest case”type: shell runs a command on the host through sh -c. Use it for git operations, host-side build steps, or any one-liner that does not need to be inside a container.
commands: format: type: shell description: Run gofmt over the whole repo cmd: gofmt -w .cmd: is a string passed to sh -c, so full shell semantics (pipes, redirects, env expansion) work. If you need to avoid the shell entirely, use argv: instead:
commands: commit-config: type: shell description: Commit a generated config file argv: - git - commit - -m - "chore: regen config" - generated.ymlcmd: and argv: are mutually exclusive.
The shell env contract
Section titled “The shell env contract”Every type: shell subprocess inherits three exported variables so it can reach the same compose project DWE is driving without rediscovery:
| Variable | Value |
|---|---|
DWE_BIN | Absolute path to the running dwe binary |
COMPOSE_PROJECT_NAME | Active compose project name |
COMPOSE_FILE | Colon-joined list of active overlay paths (absolute) |
Use "$DWE_BIN" instead of hard-coding ./bin/dwe — that keeps commands relocatable across machines:
commands: warm-cache: type: shell cmd: | "$DWE_BIN" shell app -c "php artisan cache:warm"COMPOSE_PROJECT_NAME and COMPOSE_FILE let docker compose ... invocations inside cmd: pick up DWE’s overlay set without -p / -f flags.
type: service_exec — run inside a container
Section titled “type: service_exec — run inside a container”type: service_exec runs a command inside an existing container via docker compose exec. Use this for application-level operations (artisan, manage.py, rails, mix, etc.) that should run against the live container.
commands: db.create: type: service_exec description: Create a database in the db container service: db mode: exec-or-run params: database: type: string required: true pattern: ^[a-zA-Z0-9_-]+$ env: MYSQL_PWD: "${vars.db.password}" cmd: "mariadb -u${vars.db.user} -e 'CREATE DATABASE IF NOT EXISTS `${param.database}`;'"Key fields:
-
service:— compose service name to target. -
mode:— what to do if the container is not running:exec-or-fail(default) — refuse with an actionable error suggestingdwe docker up <svc>.exec-or-run— fall back to a freshdocker compose run --rmcontainer and warn about the ephemeral run.exec— baredocker compose exec; docker emits its own error if the container is down.run— always start a fresh container.
Pick
exec-or-failfor tools that depend on persistent state (databases, app servers); pickexec-or-runonly when the tool genuinely works as a one-off (composer install on a fresh checkout, etc.). -
user:—currentruns as the host UID:GID (use for commands that write into bind mounts),rootfor privileged container ops, or any literalname/1000/1000:1000. Omit to inherit the service’scli.user. -
workdir_from:— dot-path into the merged config (e.g.services.main.work_dir_internal). Preferred over hard-codingworkdir:because it letslocal.ymloverrides reach commands.
For one-off tools that should always start fresh (artisan tinker, php -a, irb), use type: service_run — same fields, but always docker compose run --rm.
Full reference: ../reference/config/commands/types.md#type-service_exec.
type: workflow — compose multiple commands
Section titled “type: workflow — compose multiple commands”A workflow stitches existing commands into one named sequence. Use it whenever you find yourself running three or four commands in the same order, or whenever a single user action (“bootstrap”, “reset-and-reseed”) naturally spans multiple steps.
commands: bootstrap: type: workflow description: Full bootstrap — db, deps, migrate, seed steps: - command: db.start - command: db.create with: database: "${vars.db.database}" - command: composer-install - command: migrate - command: db.seed.run when: "{{ if .Params.seed }}1{{ else }}0{{ end }}" continue_on_error: true params: seed: type: bool default: trueEach step is one of three kinds (mutually exclusive):
command: <id>— invoke another command.with:overrides its params;when:skips the step when the expression is falsy;continue_on_error: trueturns a failure into a warning instead of aborting.confirm: <text>— prompt the user before continuing. Bypassed under--yesandDWE_NONINTERACTIVE=1.parallel:— fan a group of leaf command steps out concurrently. Useful for “run composer install in every app service” patterns.
when: expressions
Section titled “when: expressions”A workflow step’s when: is rendered first, then classified:
| Form | Example | Notes |
|---|---|---|
| Boolean literal | "true", "1", "" | Fast path after rendering |
| Builtin predicate | file-missing services/main/vendor/autoload.php | dir-exists, dir-missing, dir-empty, dir-not-empty, file-exists, file-missing; paths are project-root-relative |
| Shell command | cmd: test ! -d services/main/vendor | Evaluated via sh -c; exit 0 = true |
Prefer the builtin predicates when they fit — they are cheaper and don’t spawn a shell.
Full reference: ../reference/config/commands/types.md#type-workflow.
Params: typed inputs
Section titled “Params: typed inputs”Every command type can declare params: — typed inputs the user supplies via --set key=value on the CLI, or that a workflow / pipeline step passes via with:.
commands: db.create: type: service_exec service: db params: database: type: string # string (default) | bool | int | path description: Database name to create required: true default: "app" # literal fallback default_from: vars.db.database # dot-path into merged config (preferred) env: DB_NAME # exposes the resolved value as $DB_NAME pattern: ^[a-zA-Z0-9_-]+$ # anchored regex (string/path only) env: MYSQL_PWD: "${vars.db.password}" cmd: "mariadb -u${vars.db.user} -e 'CREATE DATABASE `${param.database}`;'"Resolution order, top to bottom:
- Caller-supplied value (
--set database=fooorwith: { database: foo }). default_from— dot-path into the merged DWE config. Empty result is treated as missing.- Literal
default:. - If still empty and
required: true, error.
The default_from rule lets local.yml overrides reach commands without each developer rewriting the literal default. This is the same “config wins, code provides safety net” pattern used elsewhere in DWE.
Use the resolved value with ${param.<name>} in cmd:, argv:, env:, workdir:, confirmation_text:, and file paths.
To present params as a friendly form (dropdowns, multi-select, confirm widgets) in the interactive command browser, declare widget: and options: — see param widgets.
Confirmation and --yes
Section titled “Confirmation and --yes”Any command can require a confirmation before running:
commands: db.drop: type: service_exec description: Drop a database (irreversible!) confirmation: true confirmation_text: "Drop database `${param.database}`?" service: db params: database: required: true default_from: vars.db.database env: MYSQL_PWD: "${vars.db.password}" cmd: "mariadb -u${vars.db.user} -e 'DROP DATABASE IF EXISTS `${param.database}`;'"confirmation_text: supports ${...} templating so you can echo back the values the user is about to act on. The prompt is bypassed in three cases:
- The user passed
--yes/-yon the CLI. - The command runs as a workflow sub-step under a parent that itself was started with
--yes. - A non-TTY stdin under
CI=1(auto-confirms via the plain Y/n fallback).
For scripted use, the canonical idiom is dwe cmd db.drop --set database=app --yes.
Notifications
Section titled “Notifications”notify: true opts the command into a desktop notification when it finishes (success or failure). The notification only fires when the command is the top-level invocation — workflows and pipelines never bubble inner notify: flags up to the user.
commands: db.import: type: shell notify: true cmd: | "$DWE_BIN" shell app -c "php artisan db:import ${param.dump}"Use this for long-running, “kick it off and switch to another window” commands (large imports, full bootstraps, snapshot pack/unpack). For sub-second commands, skip it — desktop popups for instant operations are noise.
notify: true on type: daemon is a validator error (daemons have no completion event), and on a parallel: sub-step it emits an info diagnostic (the runtime suppresses it anyway).
Full reference: ../reference/config/notifications.md.
Visibility: private: and hide:
Section titled “Visibility: private: and hide:”By default every command shows up in dwe commands list, the interactive browser, and tab completion. Two directives hide them:
-
private: true— static developer intent. The command is removed fromcommands list, the browser, and direct invocation viadwe cmd <id>, but is still callable from workflows and pipelines. Use it for “step” commands that should never be run directly.commands:db.up:type: dweprivate: true # used only inside db.start workflowcmd: "docker up db" -
hide:— runtime condition. Same expression syntax as workflow stepwhen:. The command appears when the expression is falsy and disappears when truthy. Typical use: tie commands to enabled services.commands:db.engine.reset:type: shellhide: '{{ eq (index .services "db" "engine") "sqlite" }}'cmd: db reset --engineA
hide:on thegroup:block hides the whole group and every descendant.
private: is appropriate for “internal plumbing” — a workflow’s atomic steps that should not be exposed. hide: is appropriate for “only relevant in some configurations” — a command that only makes sense for one of several service engines, or only when an optional service is enabled.
Cross-links
Section titled “Cross-links”../reference/config/commands/index.md— full file structure and execution lifecycle.../reference/config/commands/types.md— every command type (shell,dwe,script,service_exec,service_run,workflow,builtin,daemon) with all type-specific fields.../reference/config/commands/directives.md— every common directive in one page.../reference/config/commands/templating.md— the full${...}and{{ ... }}resolver table.background-daemons.md— thetype: daemonshape for long-running background processes.