Quellcode durchsuchen

Tooling: replace Makefile with bin/appctl bash wrapper + completion

Hand-written bash wrapper at bin/appctl (top-level ./appctl symlink)
replaces the Makefile so the project no longer needs make. Subcommand
surface follows the verb/action pattern: dev start|stop|build|shell|
logs, prod start|stop|build, plus the unchanged check trio (lint,
test, check). The css-watcher's HOST_UID/HOST_GID export and the
docker-compose.yml + docker-compose.dev.yml -f stacking are still the
underlying mechanics.

bin/appctl-completion.bash registers compgen completions for both
appctl and ./appctl. The first interactive ./appctl invocation offers
to add a source line to ~/.bashrc, gated by a marker under
~/.config/appctl/ so the prompt never repeats; APPCTL_NO_COMPLETION_PROMPT=1
silences it for CI.

Updated SPEC §3 / §11, README, doc/admin-manual.md, the /check Claude
Code skill, and the container-tester agent — every former \`make check\`
reference now points at \`./appctl check\`, so the /check pipeline keeps
working end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClaudePriv@chiappa.zhdk.ch vor 3 Stunden
Ursprung
Commit
50d34deaa2
10 geänderte Dateien mit 289 neuen und 118 gelöschten Zeilen
  1. 5 5
      .claude/agents/container-tester.md
  2. 5 5
      .claude/skills/check/SKILL.md
  3. 17 1
      CHANGELOG.md
  4. 0 72
      Makefile
  5. 13 10
      README.md
  6. 33 24
      SPEC.md
  7. 1 0
      appctl
  8. 175 0
      bin/appctl
  9. 39 0
      bin/appctl-completion.bash
  10. 1 1
      doc/admin-manual.md

+ 5 - 5
.claude/agents/container-tester.md

@@ -9,14 +9,14 @@ You are a focused test-runner agent. Your sole job is to execute the project's c
 
 ## What to run
 
-Unless the caller specifies otherwise, run **`make check`** from the repository root. That target chains:
+Unless the caller specifies otherwise, run **`./appctl check`** from the repository root. That command chains:
 
-1. `make lint` — `php -l` syntax check across `src/` and `tests/` inside a one-shot container built from the `tests` Dockerfile target.
-2. `make test` — `composer test` (PHPUnit) inside the same container.
+1. `./appctl lint` — `php -l` syntax check across `src/` and `tests/` inside a one-shot container built from the `tests` Dockerfile target.
+2. `./appctl test` — `composer test` (PHPUnit) inside the same container.
 
-Both targets internally use `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile test run --rm tests …`. You do not need to invoke docker directly — go through `make`.
+Both subcommands internally use `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile test run --rm tests …`. You do not need to invoke docker directly — go through `appctl`.
 
-If the caller asks for only one of them ("just run tests", "just lint"), run that target instead.
+If the caller asks for only one of them ("just run tests", "just lint"), run that subcommand instead.
 
 ## How to handle build time
 

+ 5 - 5
.claude/skills/check/SKILL.md

@@ -5,7 +5,7 @@ description: Run lint + PHPUnit in an isolated Docker container and get back a c
 
 # /check — run lint + tests in a one-shot container
 
-When this skill is invoked, **immediately** spawn the project's `container-tester` subagent via the Agent tool. Do not run `make check` yourself in the main session — the whole point of the skill is to keep verbose phpunit/build output out of the main context.
+When this skill is invoked, **immediately** spawn the project's `container-tester` subagent via the Agent tool. Do not run `appctl check` yourself in the main session — the whole point of the skill is to keep verbose phpunit/build output out of the main context.
 
 ## How to invoke
 
@@ -13,14 +13,14 @@ When this skill is invoked, **immediately** spawn the project's `container-teste
 Agent({
   description: "Run lint + tests in container",
   subagent_type: "container-tester",
-  prompt: "Run `make check` from the repo root. Report only failures and a one-line summary, per your output policy."
+  prompt: "Run `./appctl check` from the repo root. Report only failures and a one-line summary, per your output policy."
 })
 ```
 
 If the user passed an argument to the skill, route it:
-- `/check lint` → prompt: `"Run `make lint` only. Report per your output policy."`
-- `/check test` → prompt: `"Run `make test` only. Report per your output policy."`
-- `/check <anything else>` → pass it through as additional context: `"Run `make check`. Caller note: <argument>. Report per your output policy."`
+- `/check lint` → prompt: `"Run `./appctl lint` only. Report per your output policy."`
+- `/check test` → prompt: `"Run `./appctl test` only. Report per your output policy."`
+- `/check <anything else>` → pass it through as additional context: `"Run `./appctl check`. Caller note: <argument>. Report per your output policy."`
 
 ## After the agent returns
 

+ 17 - 1
CHANGELOG.md

@@ -6,7 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
-Nothing scheduled.
+### Changed
+
+- **Tooling: `make` → `appctl`.** The `Makefile` is gone; a hand-written
+  bash wrapper at `bin/appctl` (with a top-level `./appctl` symlink)
+  takes over. Subcommand surface follows the verb-then-action pattern:
+  `./appctl dev start|stop|build|shell|logs`,
+  `./appctl prod start|stop|build`, and the unchanged check trio
+  `./appctl lint|test|check`. `HOST_UID` / `HOST_GID` are still exported
+  for the css-watcher's bind-mount ownership; the long
+  `docker compose -f docker-compose.yml -f docker-compose.dev.yml …`
+  invocation is still the one being wrapped. Bash completion ships at
+  `bin/appctl-completion.bash`; the first interactive `./appctl`
+  invocation offers to add a `source` line to `~/.bashrc`, with a
+  marker file under `~/.config/appctl/` so the prompt only appears
+  once. Updated SPEC §3 / §11, README, `doc/admin-manual.md`, the
+  `/check` Claude Code skill, and the `container-tester` agent so
+  `make check` no longer appears anywhere.
 
 ## [0.25.0] — 2026-05-07
 

+ 0 - 72
Makefile

@@ -1,72 +0,0 @@
-# Convenience wrappers for the dev/prod compose split. Targets are split
-# along the same axis as the compose files: anything `dev-*` uses the
-# overlay, anything else hits prod.
-
-COMPOSE_PROD = docker compose
-COMPOSE_DEV  = docker compose -f docker-compose.yml -f docker-compose.dev.yml
-
-# Exported so the css-watcher service can run as the host user via
-# `user: "${HOST_UID}:${HOST_GID}"` in docker-compose.dev.yml. Files the
-# watcher writes into bind-mounted host paths (public/assets/css/app.css,
-# public/assets/js/vendor/*) then land with normal ownership.
-export HOST_UID := $(shell id -u)
-export HOST_GID := $(shell id -g)
-
-.PHONY: help dev dev-build dev-down prod prod-build prod-down \
-        test lint check shell logs
-
-help:
-	@echo "Dev:"
-	@echo "  make dev          start dev stack (app + css-watcher) in foreground"
-	@echo "  make dev-build    rebuild dev images"
-	@echo "  make dev-down     stop and remove dev containers"
-	@echo "  make shell        bash into the running app container"
-	@echo "  make logs         tail logs from the dev stack"
-	@echo ""
-	@echo "Prod:"
-	@echo "  make prod         start prod stack detached"
-	@echo "  make prod-build   rebuild prod image"
-	@echo "  make prod-down    stop and remove prod containers"
-	@echo ""
-	@echo "Checks (one-shot containers, no running stack required):"
-	@echo "  make lint         php -l on src/ + tests/"
-	@echo "  make test         phpunit"
-	@echo "  make check        lint + test (used by /check skill)"
-
-# --- dev ----------------------------------------------------------------
-dev:
-	$(COMPOSE_DEV) up
-
-dev-build:
-	$(COMPOSE_DEV) build
-
-dev-down:
-	$(COMPOSE_DEV) down
-
-shell:
-	$(COMPOSE_DEV) exec app bash
-
-logs:
-	$(COMPOSE_DEV) logs -f
-
-# --- prod ---------------------------------------------------------------
-prod:
-	$(COMPOSE_PROD) up -d
-
-prod-build:
-	$(COMPOSE_PROD) build
-
-prod-down:
-	$(COMPOSE_PROD) down
-
-# --- checks (one-shot, profile=test) ------------------------------------
-# `run --rm` builds the tests image if needed, runs the command, and
-# tears the container down. Doesn't require `make dev` to be running.
-lint:
-	$(COMPOSE_DEV) --profile test run --rm tests \
-		sh -c 'find src tests -name "*.php" -print0 | xargs -0 -n1 -P 4 php -l > /dev/null && echo "lint: OK"'
-
-test:
-	$(COMPOSE_DEV) --profile test run --rm tests
-
-check: lint test

+ 13 - 10
README.md

@@ -54,7 +54,7 @@ chmod 600 .env
 # 4. Build and start the container (compose maps host port HTTP_PORT
 #    from .env → container 80; default 8080, change in .env to taste)
 docker compose up -d --build
-#    Equivalent shortcut once `make` is installed: `make prod`
+#    Equivalent shortcut: `./appctl prod start`
 
 # 5. Open the app
 xdg-open http://localhost:8080   # substitute your HTTP_PORT if changed
@@ -89,20 +89,23 @@ are picked up on the next request and CSS changes appear without a
 container rebuild:
 
 ```bash
-make dev          # foreground; equivalent to:
-                  # HOST_UID=$(id -u) HOST_GID=$(id -g) \
-                  # docker compose -f docker-compose.yml -f docker-compose.dev.yml up
+./appctl dev start   # foreground; equivalent to:
+                     # HOST_UID=$(id -u) HOST_GID=$(id -g) \
+                     # docker compose -f docker-compose.yml -f docker-compose.dev.yml up
 ```
 
 Rebuild the dev images only when `Dockerfile`, `composer.json/.lock`, or
 `package.json/.lock` change:
 
 ```bash
-make dev-build
+./appctl dev build
 ```
 
-`make help` lists every target. Full architecture (overlay rationale,
-dev edit-cycle table, `tests` Docker stage) lives in
+`./appctl help` lists every command. The first `./appctl` invocation
+in an interactive shell offers to wire up bash completion (a `source`
+line into `~/.bashrc`); answer `n` to skip — re-asking is suppressed
+via a marker in `~/.config/appctl/`. Full architecture (overlay
+rationale, dev edit-cycle table, `tests` Docker stage) lives in
 [`SPEC.md`](SPEC.md) §11.
 
 ## Layout
@@ -133,9 +136,9 @@ extensions, and composer state as prod, plus the dev composer deps
 layered in:
 
 ```bash
-make check        # lint + phpunit, in a one-shot --rm container
-make test         # phpunit only
-make lint         # php -l on src/ + tests/
+./appctl check    # lint + phpunit, in a one-shot --rm container
+./appctl test     # phpunit only
+./appctl lint     # php -l on src/ + tests/
 ```
 
 First run of any of these cold-builds the `tests` image (~30–60s);

+ 33 - 24
SPEC.md

@@ -36,8 +36,9 @@ per-cell audit trail.
      `simplexml`, `mbstring`, `fileinfo` ship with the base image.
   4. **`tests`** (`FROM runtime`) — re-runs `composer install` to layer
      in dev deps (phpunit, etc.) and ships a `composer test` CMD. Built
-     on demand by `make test` / `make check` via the dev compose's
-     `tests` profile, never started by `make dev` or `make prod`.
+     on demand by `appctl test` / `appctl check` via the dev compose's
+     `tests` profile, never started by `appctl dev start` or
+     `appctl prod start`.
 - Language: PHP 8.3, strict types, PSR-12.
 - Database: SQLite via PDO, file at `/var/www/data/app.sqlite` (mounted volume).
 - Front end (Phase 19):
@@ -74,7 +75,9 @@ per-cell audit trail.
 ├── docker-compose.yml          # prod stack — builds target=runtime
 ├── docker-compose.dev.yml      # dev overlay — adds bind mounts, css-watcher
 │                               # sidecar, tests profile (see §11)
-├── Makefile                    # dev/prod/check wrappers; HOST_UID/GID export
+├── appctl                      # → bin/appctl symlink (dev entry point)
+├── bin/appctl                  # dev/prod/check wrappers; HOST_UID/GID export
+├── bin/appctl-completion.bash  # bash completion for appctl (auto-offered on first run)
 ├── .dockerignore
 ├── .env.example
 ├── composer.json / composer.lock
@@ -1676,18 +1679,24 @@ Nothing scheduled.
 
 ## 11. Running locally
 
-The project ships **two compose configurations** plus a Makefile that
-wraps the long `docker compose -f … -f …` invocations. Pick the one
-that matches what you're doing:
+The project ships **two compose configurations** plus the `appctl`
+shell wrapper that hides the long `docker compose -f … -f …`
+invocations. Pick the one that matches what you're doing:
 
 | Goal | Command | What runs |
 |---|---|---|
-| Run prod build (or operate it) | `make prod` | `docker-compose.yml` only — the `runtime` image with baked CSS |
-| Iterate on code locally | `make dev` | `docker-compose.yml` + `docker-compose.dev.yml` — adds source bind mounts, `APP_ENV=development`, and a `css-watcher` sidecar that runs `tailwindcss --watch` against the host |
-| Lint + tests, one-shot | `make check` | Builds the `tests` Dockerfile target on demand and runs `php -l` + PHPUnit in a `--rm` container; doesn't require `make dev` to be running |
+| Run prod build (or operate it) | `./appctl prod start` | `docker-compose.yml` only — the `runtime` image with baked CSS |
+| Iterate on code locally | `./appctl dev start` | `docker-compose.yml` + `docker-compose.dev.yml` — adds source bind mounts, `APP_ENV=development`, and a `css-watcher` sidecar that runs `tailwindcss --watch` against the host |
+| Lint + tests, one-shot | `./appctl check` | Builds the `tests` Dockerfile target on demand and runs `php -l` + PHPUnit in a `--rm` container; doesn't require the dev stack to be running |
 
-Without `make` installed (`sudo apt-get install -y make` on Debian),
-the same commands written out:
+`appctl` has no dependencies beyond bash + `docker compose`; the
+script lives at `bin/appctl` with a top-level `./appctl` symlink. The
+first interactive invocation offers to add a `source` line for
+`bin/appctl-completion.bash` to your `~/.bashrc` so tab-completion on
+`appctl dev <TAB>` etc. lights up; answer `n` to skip permanently
+(re-asking is suppressed via `~/.config/appctl/completion-installed`).
+
+Without `appctl`, the same commands written out by hand:
 
 ```bash
 # Dev (foreground; sidecar regenerates CSS on save)
@@ -1702,7 +1711,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml \
   --profile test run --rm tests
 ```
 
-`make help` lists all targets; the rest of this section is the *why*.
+`./appctl help` lists every command; the rest of this section is the *why*.
 
 ### 11.1 First-time setup
 
@@ -1710,7 +1719,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml \
 cp .env.example .env
 # Fill Entra vars, OR set LOCAL_ADMIN_EMAIL + LOCAL_ADMIN_PASSWORD_HASH
 # (see README's Quick setup for the password_hash() one-liner)
-make prod   # or `docker compose up --build`
+./appctl prod start   # or `docker compose up --build`
 # open http://localhost:8080
 ```
 
@@ -1721,7 +1730,7 @@ the request path only checks and 503s if pending, never auto-migrates).
 
 ### 11.2 Dev iteration loop
 
-`make dev` (or the long form above) starts two services:
+`./appctl dev start` (or the long form above) starts two services:
 
 - **`app`** — `runtime` image with `src/`, `views/`, `public/`,
   `assets/`, `migrations/`, `bin/`, `tailwind.config.js`,
@@ -1735,8 +1744,8 @@ the request path only checks and 503s if pending, never auto-migrates).
   `node_modules` once, then `tailwindcss --watch` regenerates
   `public/assets/css/app.css` on every save under `views/`, `src/`, or
   `public/assets/js/`. Runs as `${HOST_UID}:${HOST_GID}` (exported by
-  the Makefile) so files written into the bind-mounted host paths
-  land with normal ownership and don't need a `sudo chown` later.
+  `appctl`) so files written into the bind-mounted host paths land
+  with normal ownership and don't need a `sudo chown` later.
 
 Edit cycle:
 
@@ -1744,17 +1753,17 @@ Edit cycle:
 |---|---|
 | `.twig` template, `.php` source | next browser request (no rebuild) |
 | `.css` / new Tailwind class in a view or `.js` | watcher regenerates `app.css` in <1s; F5 |
-| `composer.json/.lock` or `package.json/.lock` | `make dev-build` |
-| `Dockerfile` | `make dev-build` |
+| `composer.json/.lock` or `package.json/.lock` | `./appctl dev build` |
+| `Dockerfile` | `./appctl dev build` |
 
 ### 11.3 Tests + lint
 
 The preferred path is one of:
 
 ```bash
-make check        # lint + phpunit, in a one-shot tests container
-make test         # phpunit only
-make lint         # php -l on src/ + tests/
+./appctl check    # lint + phpunit, in a one-shot tests container
+./appctl test     # phpunit only
+./appctl lint     # php -l on src/ + tests/
 ```
 
 All three use the dev compose's `tests` service (profile `test`), which
@@ -1764,7 +1773,7 @@ subsequent runs reuse it.
 
 The Phase 20 parser tests need `ext-dom`, `ext-zip`, `ext-xmlreader`,
 `ext-simplexml`, and `ext-gd` — all present in the runtime image, so
-running them via `make test` is always green. On hosts where you'd run
+running them via `./appctl test` is always green. On hosts where you'd run
 PHPUnit directly without Docker, those parser tests auto-skip via
 `extension_loaded()` in `setUp()`.
 
@@ -1775,14 +1784,14 @@ during writing:
 for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; done
 ```
 
-…but `make lint` does the same thing inside the container with the
+…but `./appctl lint` does the same thing inside the container with the
 known-good extension set.
 
 ### 11.4 Running checks via the `/check` Claude Code skill
 
 A project-level skill at `.claude/skills/check/SKILL.md` invokes a
 Haiku-powered subagent (`.claude/agents/container-tester.md`) that runs
-`make check` and reports back **only** failures + a one-line summary.
+`./appctl check` and reports back **only** failures + a one-line summary.
 Verbose phpunit / docker build output is consumed by the agent and
 never enters the main session context.
 

+ 1 - 0
appctl

@@ -0,0 +1 @@
+bin/appctl

+ 175 - 0
bin/appctl

@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+# appctl — convenience wrapper for the dev/prod compose split.
+#
+# Replaces the old Makefile. The dev compose chain layers
+# docker-compose.dev.yml on top of docker-compose.yml so source bind
+# mounts and the css-watcher sidecar only show up for `appctl dev …`.
+# Prod uses docker-compose.yml alone.
+
+set -euo pipefail
+
+# Resolve the repo root (parent of bin/) regardless of where the user
+# invokes us from, so cd into the right place even when called via the
+# top-level ./appctl symlink.
+SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
+REPO_ROOT="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"
+cd "$REPO_ROOT"
+
+# Exported so the css-watcher service can run as the host user via
+# `user: "${HOST_UID}:${HOST_GID}"` in docker-compose.dev.yml. Files
+# the watcher writes into bind-mounted host paths
+# (public/assets/css/app.css, public/assets/js/vendor/*) then land
+# with normal ownership.
+export HOST_UID="${HOST_UID:-$(id -u)}"
+export HOST_GID="${HOST_GID:-$(id -g)}"
+
+COMPOSE_PROD=(docker compose)
+COMPOSE_DEV=(docker compose -f docker-compose.yml -f docker-compose.dev.yml)
+
+usage() {
+    cat <<'EOF'
+appctl — Sprint Planner dev/prod/test wrapper
+
+USAGE
+  appctl <command> [subcommand]
+
+DEV
+  appctl dev start       start dev stack (app + css-watcher) in foreground
+  appctl dev stop        stop and remove dev containers
+  appctl dev build       rebuild dev images
+  appctl dev shell       bash into the running app container
+  appctl dev logs        tail logs from the dev stack
+
+PROD
+  appctl prod start      start prod stack detached
+  appctl prod stop       stop and remove prod containers
+  appctl prod build      rebuild prod images
+
+CHECKS (one-shot containers, no running stack required)
+  appctl lint            php -l on src/ + tests/
+  appctl test            phpunit
+  appctl check           lint + test (used by the /check Claude Code skill)
+
+OTHER
+  appctl completion      print the bash completion script to stdout
+  appctl help            show this message
+EOF
+}
+
+die() {
+    printf 'appctl: %s\n' "$*" >&2
+    exit 2
+}
+
+# --- bash completion auto-source on first run --------------------------
+#
+# We ship bin/appctl-completion.bash next to this script. On the very
+# first interactive invocation we offer to add a `source` line to the
+# user's ~/.bashrc — only once, gated by a marker file in $HOME so the
+# prompt never appears again, and only when stdin is a TTY (CI / piped
+# invocations are silent).
+maybe_offer_completion() {
+    [[ -t 0 && -t 1 ]] || return 0
+    [[ "${APPCTL_NO_COMPLETION_PROMPT:-}" != "1" ]] || return 0
+
+    local marker="${HOME}/.config/appctl/completion-installed"
+    [[ ! -e "$marker" ]] || return 0
+
+    local completion_script="${REPO_ROOT}/bin/appctl-completion.bash"
+    [[ -f "$completion_script" ]] || return 0
+
+    local bashrc="${HOME}/.bashrc"
+    if [[ -f "$bashrc" ]] && grep -Fq "appctl-completion.bash" "$bashrc"; then
+        # Already wired up by a previous repo checkout; just write the
+        # marker so we stop asking.
+        mkdir -p "$(dirname "$marker")"
+        : > "$marker"
+        return 0
+    fi
+
+    printf '\n[appctl] bash completion is not yet installed.\n'
+    printf '        Add `source %s` to %s? [y/N] ' \
+        "$completion_script" "$bashrc"
+    local reply
+    read -r reply || reply=""
+    case "$reply" in
+        y|Y|yes|YES)
+            mkdir -p "$(dirname "$marker")"
+            {
+                printf '\n# appctl bash completion (added by appctl on first run)\n'
+                printf 'source %q\n' "$completion_script"
+            } >> "$bashrc"
+            : > "$marker"
+            printf '[appctl] added. Open a new shell or `source %s` to activate.\n\n' "$bashrc"
+            ;;
+        *)
+            mkdir -p "$(dirname "$marker")"
+            : > "$marker"
+            printf '[appctl] skipped. Re-run with APPCTL_NO_COMPLETION_PROMPT=1 to silence; delete %s to ask again.\n\n' "$marker"
+            ;;
+    esac
+}
+
+# --- subcommands -------------------------------------------------------
+
+cmd_dev() {
+    local sub="${1:-}"
+    case "$sub" in
+        start)  "${COMPOSE_DEV[@]}" up ;;
+        stop)   "${COMPOSE_DEV[@]}" down ;;
+        build)  "${COMPOSE_DEV[@]}" build ;;
+        shell)  "${COMPOSE_DEV[@]}" exec app bash ;;
+        logs)   "${COMPOSE_DEV[@]}" logs -f ;;
+        ""|help|-h|--help) usage ;;
+        *) die "unknown dev subcommand: $sub (try: start|stop|build|shell|logs)" ;;
+    esac
+}
+
+cmd_prod() {
+    local sub="${1:-}"
+    case "$sub" in
+        start)  "${COMPOSE_PROD[@]}" up -d ;;
+        stop)   "${COMPOSE_PROD[@]}" down ;;
+        build)  "${COMPOSE_PROD[@]}" build ;;
+        ""|help|-h|--help) usage ;;
+        *) die "unknown prod subcommand: $sub (try: start|stop|build)" ;;
+    esac
+}
+
+cmd_lint() {
+    "${COMPOSE_DEV[@]}" --profile test run --rm tests \
+        sh -c 'find src tests -name "*.php" -print0 | xargs -0 -n1 -P 4 php -l > /dev/null && echo "lint: OK"'
+}
+
+cmd_test() {
+    "${COMPOSE_DEV[@]}" --profile test run --rm tests
+}
+
+cmd_check() {
+    cmd_lint
+    cmd_test
+}
+
+cmd_completion() {
+    local completion_script="${REPO_ROOT}/bin/appctl-completion.bash"
+    [[ -f "$completion_script" ]] || die "completion script missing: $completion_script"
+    cat "$completion_script"
+}
+
+# --- dispatch ----------------------------------------------------------
+
+maybe_offer_completion
+
+cmd="${1:-help}"
+shift || true
+
+case "$cmd" in
+    dev)         cmd_dev "$@" ;;
+    prod)        cmd_prod "$@" ;;
+    lint)        cmd_lint ;;
+    test)        cmd_test ;;
+    check)       cmd_check ;;
+    completion)  cmd_completion ;;
+    help|-h|--help|"") usage ;;
+    *) die "unknown command: $cmd (try: appctl help)" ;;
+esac

+ 39 - 0
bin/appctl-completion.bash

@@ -0,0 +1,39 @@
+# Bash completion for appctl. Source this from your ~/.bashrc:
+#   source /path/to/sprint_planer_web/bin/appctl-completion.bash
+# `appctl` will offer to add that line on first run.
+
+_appctl_complete() {
+    local cur prev cword
+    COMPREPLY=()
+    cur="${COMP_WORDS[COMP_CWORD]}"
+    cword="${COMP_CWORD}"
+    prev="${COMP_WORDS[cword-1]}"
+
+    local top="dev prod lint test check completion help"
+
+    if (( cword == 1 )); then
+        # shellcheck disable=SC2207
+        COMPREPLY=( $(compgen -W "${top}" -- "${cur}") )
+        return 0
+    fi
+
+    if (( cword == 2 )); then
+        case "${COMP_WORDS[1]}" in
+            dev)
+                # shellcheck disable=SC2207
+                COMPREPLY=( $(compgen -W "start stop build shell logs" -- "${cur}") )
+                return 0
+                ;;
+            prod)
+                # shellcheck disable=SC2207
+                COMPREPLY=( $(compgen -W "start stop build" -- "${cur}") )
+                return 0
+                ;;
+        esac
+    fi
+
+    return 0
+}
+
+complete -F _appctl_complete appctl
+complete -F _appctl_complete ./appctl

+ 1 - 1
doc/admin-manual.md

@@ -283,7 +283,7 @@ container to the host.
 > production deployment path: the bare `docker compose` commands
 > targeting `docker-compose.yml` only. Developers iterating on code
 > use a separate `docker-compose.dev.yml` overlay (bind mounts +
-> `tailwindcss --watch` sidecar) plus a `Makefile` of wrappers; that
+> `tailwindcss --watch` sidecar) plus the `appctl` shell wrapper; that
 > story lives in `SPEC.md` §11 and is not relevant to operating a
 > deployment.