commands/
Declarative command definitions for the DWE project.
Contents
Section titled “Contents”- Purpose
- File layout and command IDs
- File structure
- Execution lifecycle
- Visibility, registration, and discovery
- End-to-end examples
- Related commands
- Interactive browser
- Further reading
Purpose
Section titled “Purpose”workspace/commands/ is the home of every reusable, scriptable action the project exposes through the CLI: container shells, build steps, database operations, multi-step workflows, deploy hooks, custom scripts, etc.
Each YAML file declares one or more named commands. Commands are discovered automatically by walking the directory tree, are addressable by a dot-separated ID, and can be executed directly (dwe commands <id>) or referenced from workflows and pipelines (deploy.yml, lifecycle.yml).
This page is the entry point to the configuration reference. Directives, types, templating, and validation each have their own sibling page — see Further reading at the bottom.
File layout and command IDs
Section titled “File layout and command IDs”The directory layout determines the group prefix and the file’s basename determines the leaf segment. Files and subdirectories are entirely up to the project — names below are illustrative, and any number of files at any depth may exist.
workspace/commands/├── <top-group>.yml → group: <top-group>├── … → any number of top-level groups└── <parent-group>/ → optional subdirectory expands a group ├── <child>.yml → group: <parent-group>.<child> ├── <child>/ → optional deeper subdirectory │ └── <leaf>.yml → group: <parent-group>.<child>.<leaf> └── … → any number of children, any depthConcretely, a project might lay things out like this — every name here is the project’s choice, not a convention enforced by the CLI:
workspace/commands/├── db.yml → group: db├── app.yml → group: app└── services/ ├── <service-a>.yml → group: services.<service-a> ├── <service-a>/ │ └── db.yml → group: services.<service-a>.db └── <service-b>.yml → group: services.<service-b>Each command’s full ID is <group>.<name> where <name> is its key inside the file’s commands: map.
Pattern: put the core commands of a group in a single file named after the group (services/<service>.yml), and split larger groups into a sibling subdirectory only when there are enough commands to warrant logical sub-groups (services/<service>/db.yml, services/<service>/cache.yml). The subdirectory is optional — small groups stay in one file. There are no required files: a project may have zero, one, or dozens of groups at any depth.
# workspace/commands/db.yml → group "db"commands: cli: # full ID: db.cli type: service_exec ... dump-create: # full ID: db.dump-create type: script ...There are no reserved file names — every *.yml file contributes a segment derived from its path.
File structure
Section titled “File structure”Each file has two top-level keys: an optional group: block of metadata, and a required commands: map.
group: title: Database description: Database container management commands
commands: <local-name>: type: <type> description: <text> # ... directives below ...| Field | Type | Description |
|---|---|---|
group.title | string | Display title shown by dwe commands list |
group.description | string | Short description shown next to the group; like a command’s, only its first non-empty line is shown there (see Description and summary) |
group.hide | string | Optional condition expression; when truthy, hides the group and cascades to every descendant (commands and sub-groups). See Hide condition. |
commands | map | Named command definitions (key = local name) |
Execution lifecycle
Section titled “Execution lifecycle”Every command, regardless of type, flows through the same execution pipeline:
flowchart TD
A[Resolve params] --> B[Resolve context]
B --> C[Compute file paths]
C --> D{Confirmation?}
D -- yes --> E[Prompt user]
D -- no --> F[Prepare file effects]
E --> F
F --> G[Dispatch runner]
G --> H{Success?}
H -- yes --> I[Emit success message]
H -- no --> J[Run cleanups in LIFO order]
J --> K[Emit error message]
Phases:
-
Resolve params — for each declared parameter try, in order: caller-supplied value →
default_from(dot-path into the merged config; empty result is treated as missing) → literaldefault→ required-error. Then coerce to the declared type and validatepattern. -
Resolve context — read each
context.<key>.fromdot-path out of the merged config.dwe validatewarns (domaincommands) when one of these dot-paths does not resolve in the merged config. The consequence differs per field: an unresolvedparams.<name>.default_fromfalls through todefault:, or fails the parameter when it isrequired:with no other value; an unresolvedparams.<name>.options.fromyields an empty option list, so a select form offers no choices; an unresolvedcontext.<name>.fromrenders as an empty string.dwe validate --strictturns the warning into an error. -
Compute file paths — render
path/candidatestemplates, normalise to absolute, discover files. Non-mutating. -
Confirmation — when
confirmation: true, prompt the user; the prompt is bypassed only bySkipConfirm(set by--yes/-yand inherited by workflow children). Otherwise dispatch is by stdin: TTY →huh.Confirm, non-TTY → plain Y/n fallback that auto-answers “yes” when theCIenvironment variable is set (to any non-empty value). Refusal aborts the command. See Confirmation flow for the full decision tree. -
Prepare file effects —
mkdir,overwritechecks, register cleanup callbacks. -
Run — dispatch to the type-specific runner (host shell, DWE CLI, container exec/run, script, or workflow).
-
Success / error — emit
messages.successormessages.error. On error, registered cleanups fire in LIFO order before the error message.
Visibility, registration, and discovery
Section titled “Visibility, registration, and discovery”- Files under
workspace/commands/are discovered recursively at startup. - Each file is parsed and validated; a load failure halts startup with a structured error pointing to the file and field.
private: truehides commands fromdwe commands listand rejects direct invocation viadwe commands. Private commands can still be referenced from workflows and pipelines — useful for steps that should only run as part of a larger sequence.
db.up: type: dwe private: true # used only inside db.start workflow cmd: "docker up db"End-to-end examples
Section titled “End-to-end examples”A self-contained service_exec command
Section titled “A self-contained service_exec command”db.create: type: service_exec description: Create a database in the db container service: db params: database: type: string required: true pattern: ^[a-zA-Z0-9_-]+$ env: MYSQL_PWD: "${vars.db.password}" messages: success: "Database `${param.database}` is ready." error: "Failed to create database `${param.database}`." cmd: "mariadb -u${vars.db.user} -e 'CREATE DATABASE IF NOT EXISTS `${param.database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"A script command with file artefacts
Section titled “A script command with file artefacts”db.dump-create: type: script description: Create a database dump file params: database: type: string default_from: vars.db.database pattern: ^[a-zA-Z0-9_-]+$ dump_dir: type: string default_from: vars.db.backup_dir required: true pattern: ^[^*?\[\]]+$ dump_date: type: bool default: true env: DB_NAME: "${param.database}" DB_USER: "${vars.db.user}" MYSQL_PWD: "${vars.db.password}" files: dump: access: write path: "${param.dump_dir}/${param.database}{{ if .Params.dump_date }}_{{ now | date \"2006-01-02\" }}{{ end }}.sql.gz" mkdir: true overwrite: true on_error: remove env: DUMP_FILE script: path: workspace/scripts/db/dump-create.sh shell: bash messages: success: "Database dump created at ${files.dump.path}" error: "Failed to create database dump"A read-with-fallback file spec
Section titled “A read-with-fallback file spec”db.dump-deploy: type: script confirmation: true confirmation_text: "This will DROP and recreate `${param.target_database}`. Continue?" params: target_database: default_from: vars.db.database required: true dump_dir: default_from: vars.db.backup_dir required: true files: dump: access: read candidates: - glob: "${param.dump_dir}/${param.target_database}_*.sql.gz" match: '\d{4}-\d{2}-\d{2}' sort: name_desc - path: "${param.dump_dir}/${param.target_database}.sql.gz" required: true env: DUMP_FILE script: path: workspace/scripts/db/dump-deploy.sh shell: bashA workflow with conditional and confirm steps
Section titled “A workflow with conditional and confirm steps”reset-and-bootstrap: type: workflow description: Drop, recreate, and bootstrap the main service steps: - confirm: "Drop and re-bootstrap `${vars.db.database}`?"
- command: db.drop with: database: "${vars.db.database}"
- command: services.main.db.create
- command: services.main.composer-install when: "file-missing services/main/src/vendor/autoload.php"
- command: services.main.migrate
- command: optional-cache-warm continue_on_error: trueA private composition
Section titled “A private composition”db.up: type: dwe private: true description: Start the database container in the background cmd: "docker up db"
db.wait: type: builtin private: true description: Wait for the db container to become healthy cmd: docker_wait_healthy with: services: [db] timeout: 120s interval: 2s
db.start: type: workflow private: true description: Start the database container and wait until healthy steps: - command: db.up - command: db.waitdb.start cannot be invoked directly via dwe commands db.start, but bootstrap can reference it from its steps:. The composition above is the canonical pattern: a thin type: dwe for the start, a type: builtin for the wait, and a type: workflow that strings them together.
Custom network names + partial
up/run. If your compose file gives a network an explicitnetworks.<x>.name:, be aware of a docker-compose quirk: the first command to materialize that network wins its labels. A partialdocker up dbor atype: service_runstep (which runsdocker compose run --rm --no-deps …) executed before the full stack comes up can create the named network with labels a laterup --waitthen rejects (network <x> … has incorrect label com.docker.compose.network). This is upstream compose behavior, not a DWE bug — DWE declares no networks of its own and passes the same project/-fset to every invocation. The fix is ordering: bring the whole stack up (docker up --wait, the built-in final deploy phase) before any partialup <svc>orrun --rmthat touches the custom-named network, or drop the explicitname:and let compose scope the network to the project.
Related commands
Section titled “Related commands”dwe commands list— list all public commands grouped by filedwe commands <id> [--set k=v] [--yes] [-- <args>]— execute a command (alias:dwe cmd <id>). Everything after--is offered to the command as${args}, opt-in per command — see directives § Pass-through argumentsdwe commands --inspect <id>(or-i) — show the resolved definition (params, context, env, runner)dwe docs generate— regenerate the per-command reference underdocs/reference/commands/
When dwe commands is invoked without an exact command ID on an interactive terminal — and without --inspect, which requires one — an interactive two-panel command browser opens; see Interactive browser.
--inspect / -i is mutually exclusive with --set and --yes; it requires an exact command id and prints the definition without running it.
JSON listing
Section titled “JSON listing”dwe commands list [group] --output json prints an object whose commands field is one flat, id-sorted array. The optional group narrows it to that group and everything nested under it (admin matches admin.lint, not administration):
{"commands":[ {"id":"app.install","group":"app","title":"install","description":"Install application dependencies","summary":"Install application dependencies","type":"service_exec","service":"app-main","params":[{"name":"env","type":"string","required":true}]}, {"id":"db.migrate","group":"db","title":"migrate","description":"Run database migrations","summary":"Run database migrations","type":"shell"}]}| Key | Notes |
|---|---|
id, title, type | always present; title is the last segment of id |
group | omitted for a command outside any group |
description | omitted when empty. The full text. Localized: with a non-English locale it carries the translation from workspace/i18n/<lang>.yml, so match commands on id, never on this text |
summary | omitted when empty. The first non-empty line of description — see Description and summary. dwe commands -i <id> --output json carries the same pair |
service | omitted when the command declares none. The declared value, not a rendered one — a templated service: app-${param.service} is printed verbatim, so it is not always a literal container name. The .start / .logs / .stop / .restart commands a type: daemon expands into carry the daemon’s own service |
private | only with --all |
params | omitted when the command has none; full parameter details are in dwe commands -i <id> --output json |
description and service let an agent pick a command from the listing alone: to find what runs in a container, filter on service; to find what a command is for, read description. Commands hidden by an active hide: are absent, as in the text listing.
Interactive browser
Section titled “Interactive browser”The browser runs on the shared tui framework: two bordered panels (tree on the left, command list on the right), a bottom status line (brand · breadcrumb · ? help), and a ?-triggered modal help that lists the bindings active in the current mode. Panel focus cycles with Tab / Shift+Tab; the focused panel is highlighted by its border. The mouse is supported (see Mouse).
It takes no configuration: top-level groups are expanded on entry, empty subtrees collapse during fuzzy filtering, and type badges are on. This describes the full two-panel frame; on a terminal narrower than 100 columns badges and group counts drop, and below 80 columns or 15 rows the browser degrades to a flat list — see Fallback ladder.
Hotkeys
Section titled “Hotkeys”Focus moves between the two panels with Tab / Shift+Tab. Navigation and select bindings are dispatched to the focused panel; tree-only verbs (→/l, ←/h) are inert in the list, and the run-mode verbs (e, y) are absent outside run mode. The ? modal lists exactly the bindings active in the current mode.
| Binding | Action |
|---|---|
Tab / Shift+Tab | cycle focus between the tree and the list panel |
↑/↓, k/j | move the cursor in the focused panel |
→, l | expand the focused group, or step into its first child (tree only; no-op in the list) |
←, h | collapse the focused group, or step to its parent (tree only; no-op in the list) |
Home / End | jump to the first / last row of the focused panel |
PgUp / PgDn | scroll the focused panel one viewport |
Enter | on a tree group: toggle expansion; on a list item: confirm the selection (run, inspect, or edit, depending on entry point) |
/ | enter inline filter mode |
i | open the inspect overlay for the highlighted command |
y | toggle skip-confirm (run mode only); the status line shows [--yes ON] |
e | confirm the highlighted command and force the param form open (run mode only) |
? | toggle the modal help |
Esc, q, Ctrl+C | exit the browser |
While the inline filter is active (/), typed characters refine the fuzzy query — the query line renders inside the tree panel, the tree narrows live with updated M/N match counts, Enter commits the filtered expansion, and Esc restores the prior expansion state. Action letters (i, e, y, …) are typed into the query, not dispatched, while filtering.
Use ↑/↓ to highlight a search result. The first Enter closes the query and focuses the command’s group, keeping that same command highlighted in the full group list. Press Enter again to select it; closing the query alone never runs a command.
While the inspect overlay is open (i), it captures input: ↑/↓, k/j, PgUp/PgDn, Home/End scroll the centred viewport, Enter confirms (returns the same Result as Enter from the list), and Esc closes the overlay.
Parameter form (in-TUI overlay)
Section titled “Parameter form (in-TUI overlay)”On the two-panel frame path (TTY ≥ 80 cols), selecting a command that takes parameters opens the param form as an overlay over the browser — the browser dims beneath it and the status line stays visible — instead of tearing the TUI down first. You fill the parameters in place; multi-field forms scroll inside the overlay (the focused field stays visible). On submit the overlay closes, the browser exits, and the command runs in the plain terminal (banner + streamed output) with the values you entered. Esc cancels the form and returns you to the browser with no command run and the cursor / expansion / filter state intact; Ctrl+C quits the whole TUI.
Enter auto-skips the form when every required parameter already has a value (from --set or a declared default) — the command runs immediately with no overlay. Use e (edit-parameters) to force the form open even when the required values are already satisfied. A command with no parameters always runs immediately on Enter.
The command still executes after the TUI exits (it streams docker / pipeline output to the plain terminal); only parameter entry moves into the overlay. A confirmation: prompt, if declared, still shows its yes/no in the plain terminal after the TUI exits, immediately before the run. On the narrow (< 80-col) fallback the browser keeps the flat exit-then-form flow. Direct dwe commands <id> (with or without --set), piped / non-interactive, and --output json invocations are unaffected. (A bare dwe commands --set … with no id still opens the browser on a TTY — the --set values just prefill the overlay.)
The e (edit-parameters) and y (skip-confirm) bindings are only registered in run mode (the default when --inspect / -i is not set); in inspect and the vars-browser edit mode they are absent from both the keymap and the ? help.
Fallback ladder
Section titled “Fallback ladder”The browser inspects the terminal at startup and degrades gracefully. Below the two-panel minimum (or on a size-read failure) it drops straight to the flat huh.NewSelect list — there is no in-TUI single-panel mode.
| Condition | Behaviour |
|---|---|
| non-TTY | the call site short-circuits with the existing no exact command ID given; pass a full command ID or run in an interactive terminal error; the browser is never reached (and Run defensively cancels if it is) |
TTY, width < 80 or height < 15 | delegate to the flat huh.NewSelect list (the pre-browser UX) |
TTY, width ∈ [80, 99] | two-panel frame without (N) group counts and without type badges |
TTY, width ≥ 100 | full two-panel frame with badges and counts (the breadcrumb is always shown in the bottom status line) |
NO_COLOR=1 is honoured automatically via lipgloss/bubbletea: badges render as plain text and the focus marker uses bold instead of colour.
The frame enables mouse support:
- Single click in a panel moves the cursor to the clicked row and sets focus to that panel; it never toggles a group or runs a command.
- Double click acts as
Enteron the clicked row — it toggles a tree group or confirms a list item. - Wheel scrolls the panel under the pointer (the tree, the command list, or an open inspect overlay) without changing focus — it works regardless of which panel is focused.
- Clicking the
? helphint in the status line toggles the modal help; clicks inside an open modal are swallowed.
Further reading
Section titled “Further reading”- directives.md — common fields: identity, confirmation, messages, notifications, params, context, env, files
- types.md — the eight command types and their type-specific fields, plus workdir resolution
- templating.md — render context, command-scope resolvers, template-space reference
- validation.md — validation rules cheat sheet and common pitfalls
styles.md— palette keys consumed by the browser’s type badges