Перейти к содержанию

Развёртывание и расширение

Чтобы перенести flower в другое место, нужно решить три вещи: контейнер (обнести неограниченный Bash и заодно проверить фразу «без CLI»), plugin (доменные возможности едут вместе с репозиторием и не зависят от того, что установлено на хосте), сайт документации (push в main — и публикация происходит сама, install.sh висит на домене Pages). Три раздела независимы, читайте по необходимости.

1. Контейнер

Зачем нужен контейнер

Первое — обнести. У работающего исполнителя неограниченный Bash — белый список Bash во flower (delegate_guard) распространяется только на главный поток, а тот, кого отправили наружу, должен уметь гонять тесты, так что это сделано намеренно. В контейнер монтируется только каталог вашего проекта, исходники фреймворка лежат внутри образа в /opt/flower, остального на хосте не видно.

Второе — сам по себе он и есть проверка ограничения переносимости. В образе нет Claude Code CLI, нет Node, только Python и claude-agent-sdk — запросы уходят через нативный бинарник, который поставляется в wheel. Если здесь всё запускается, то «без CLI» — не декларация на бумаге.

Проверено на практике (2026-09-06, macOS 15 / arm64 / colima + docker 28.4.0):

Что проверяли Результат
Есть ли CLI в образе claude, node, npm, npxни одного нет
Встроенный бинарник \177ELF (207M)
Реальный запрос отправлен через cloud.infini-ai.com/maas и получен ответ, $0.1741 / 1 раунд (для Opus 5 + окно 1M это и есть нижняя граница одного раунда)
Владелец файлов файл, записанный внутри контейнера в /work, на хосте принадлежит hechenyu:staff — маппинг корректный
Видимость хоста в контейнере ls /UsersNo such file or directory

Что установлено в образе

Базовый образ python:3.13-slim, поверх него apt ставит всего три пакета. У каждого есть причина:

Что ставим Зачем
python:3.13-slim Нужен только Python ≥ 3.10. Ни Node, ни claude CLI
git --isolate выдаёт каждому subagent отдельный worktree
ca-certificates Ходим через HTTPS-шлюз
libstdc++6 Встроенный в SDK бинарник — однофайловая сборка Bun, на Linux ему нужна эта библиотека; в slim-образе её нет

Исходники фреймворка попадают в образ через COPY, а не bind mount — поэтому агент внутри контейнера не дотянется до исходников фреймворка на хосте:

Путь в образе Содержимое Откуда
/opt/flower pyproject.toml, flower/, examples/, здесь же выполняется pip install . COPY
/work Рабочий каталог (WORKDIR), в рантайме сюда монтируется $PWD хоста docker run -v

Точка входа — ENTRYPOINT ["flower"], CMD пустой: запуск контейнера без аргументов уходит в интерактивный ввод (он спрашивает, что нужно сделать), а не печатает --help. Так не приходится брать в кавычки формулировку задачи в shell.

Почему нельзя примонтировать .venv с хоста

SDK публикует wheel по платформам, и встроенный бинарник платформозависим:

Хост      claude_agent_sdk-0.2.152-py3-none-macosx_11_0_arm64.whl
          → _bundled/claude — это Mach-O 64-bit arm64, 191M
Контейнер claude_agent_sdk-0.2.152-py3-none-manylinux_2_17_aarch64.whl

Примонтированный он не запустится, поэтому образ обязан делать свой pip install. С другой стороны, это и есть доказательство переносимости: один и тот же pyproject.toml, смена платформы меняет нативный бинарник, а код фреймворка не правится ни на строку.

Два скрипта

Скрипт Что делает
docker/build Собирает образ. cd в корень репозитория, docker build -f docker/Dockerfile -t flower-box .; при FLOWER_MIRRORS=1 (по умолчанию) сначала тянет python:3.13-slim из зеркала registry и делает retag, а также передаёт --build-arg для pip / apt
docker/flowerbox Один запуск. Проверяет файл с учётными данными → проверяет, монтируется ли $PWD → определяет наличие TTY → docker run

Переключатели docker/build — все через переменные окружения:

Переменная По умолчанию Смысл
FLOWER_IMAGE flower-box Тег образа
FLOWER_MIRRORS 1 0 = не подменять ни одного зеркала, всё с upstream
FLOWER_REGISTRY dockerproxy.net Отсюда тянется базовый образ и переименовывается в python:3.13-slim, чтобы FROM попал в локальный
FLOWER_PIP_INDEX https://mirrors.aliyun.com/pypi/simple/ Передаётся в --build-arg PIP_INDEX_URL
FLOWER_APT_MIRROR mirrors.ustc.edu.cn Передаётся в --build-arg APT_MIRROR

Последние три работают только при FLOWER_MIRRORS=1 — ветка FLOWER_MIRRORS=0 вообще не выставляет build-arg.

docker/flowerbox понимает две:

Переменная По умолчанию Смысл
FLOWER_HOME Уровнем выше расположения самого скрипта (то есть корень репозитория) Где искать .env. Если $FLOWER_HOME/.env не найден — сразу выход с кодом 1
FLOWER_IMAGE flower-box Какой образ запускать

FLOWER_HOME выводится из положения самого скрипта, путь нигде не зашит, поэтому репозиторий можно клонировать куда угодно.

Собрать образ и поднять контейнер

docker/build                       # достаточно одного раза
cd ~/любой-каталог-проекта          # обязательно внутри $HOME, см. границы монтирования ниже
/path/to/flower/docker/flowerbox   # без аргументов → он сам спросит, что делать, кавычки не нужны

Если сеть до pypi.org / Docker Hub в порядке, сборка запускается так:

FLOWER_MIRRORS=0 docker/build

Аргументы flowerbox полностью совпадают с flower — он подставляет "$@" как есть после ENTRYPOINT. --clarify-only, --asks N, --timeout секунды, --isolate, -v передаются напрямую, полная таблица — в командной строке:

cd ~/proj
/path/to/flower/docker/flowerbox --clarify-only -v
/path/to/flower/docker/flowerbox "Сделай мне X"

Реально выполняется вот эта строка (-t добавляется только при наличии TTY, см. ниже):

docker run -i $TTY --rm \
    --env-file "$FLOWER_HOME/.env" \
    -v "$PWD:/work" \
    -w /work \
    "$IMAGE" "$@"

Границы монтирования и персистентность

$PWD хоста ──монтируется──>  /work       ← здесь работает агент, результаты остаются на хосте
внутри образа                /opt/flower ← исходники фреймворка, **не смонтированы**, до хоста не достать

Поэтому запускаться в подкаталоге репозитория вроде flower/human-test/HT001 тоже безопасно: внутрь монтируется только HT001, исходники фреймворка в область монтирования не попадают.

Что Останется после выхода Почему
Всё под $PWD хоста, включая runs/ и верстак .flower/ Да Это и есть тот каталог, который смонтирован как /work
Записанное по другим путям внутри контейнера Нет --rm, контейнер удаляется при выходе
Учётные данные Не попадают в слои образа Идут через --env-file; .env исключён в .dockerignore, так что даже COPY . . его не затащит

Каталог проекта обязан быть внутри $HOME, иначе результаты молча теряются

colima по умолчанию монтирует в VM только $HOME (mount | grep virtiofsmount0 on /Users/<вы>). Если запускать где-нибудь в /tmp, -v создаст в VM пустой каталог, и записанное туда хост никогда не увидит, причём без ошибки — результаты, бриф, runs/ теряются целиком. Наступали один раз: прогон once отработал, $0.17 потрачены, а runs/ на хосте попросту нет.

Сейчас flowerbox такое отсекает: если $PWD внутри $HOME — пропускает сразу; если нет — пишет в $PWD файл-пробу и поднимает ещё один контейнер, который фактически проверяет test -f /work/<проба> (с дополнительными монтированиями тоже проходит). Не прошло — выход 1 и подсказка colima start --mount '<путь>:w'. Для пробы нужен контейнер, так что сначала docker/build.

Как учётные данные попадают в контейнер

Через docker run --env-file, в слои образа они не попадают. flowerbox читает $FLOWER_HOME/.env, по умолчанию это .env в корне репозитория:

cp .env.example .env       # впишите токен; .env уже в gitignore

Учтите: flower setup пишет в ~/.config/flower/.env, и этот путь flowerbox не смотрит. Если вы уже настроились через setup и не хотите держать вторую копию, направьте туда FLOWER_HOME:

FLOWER_HOME=~/.config/flower /path/to/flower/docker/flowerbox

Имена ключей, приоритеты и как заполнять шлюз — см. конфигурацию.

Без TTY вопросы будут висеть до самого --timeout

flowerbox добавляет -t только при [ -t 0 ]docker run -t в конвейере / CI сразу ругается «the input device is not a TTY»; -i нужен всегда, иначе stdin вообще не пройдёт внутрь.

Ответы на вопросы идут через стандартный ввод. Без TTY input() уже на первом вызове бросает EOFError → текущий вопрос считается «ввод закрыт» и пропускается, а поток ответов сразу завершается, поэтому начиная со второго вопроса отвечать некому — остаётся только ждать до конца --timeout (по умолчанию 1800 секунд). Для автономных запусков нужен явный --timeout 0. Обнаружив отсутствие TTY, скрипт сначала печатает предупреждение.

git submodule

В .gitmodules всего одна запись:

path url Что это
human-test/HT001 https://github.com/ChenyuHeee/cppide.git Репозиторий кода, произведённый тем прогоном HT001, хранится для истории

Обычный git clone его не подтянет, и human-test/HT001 останется пустым каталогом (git submodule status покажет - в начале строки — это оно). Нужно ли с этим что-то делать:

Что вы хотите Нужна ли инициализация
Запускать flower, собирать образ Нет. human-test/ исключён в .dockerignore, а Dockerfile и так COPY только pyproject.toml / flower / examples
Полистать локально код, произведённый в HT001 Да: git submodule update --init human-test/HT001 либо сразу git clone --recurse-submodules

Сеть внутри Китая: откуда взялась вся эта пачка подмен зеркал

При установке этого хозяйства за файрволом узкое место — не полоса, а международные каналы. Дефолтный docker/build уже подменяет всё, что нужно, а FLOWER_MIRRORS=0 разом всё отключает. Ниже — замеры и происхождение четырёх подмен; если у вас с сетью такой проблемы нет, читать не нужно.

Таблица замеренных скоростей и четыре подмены (2026-09-06, macOS/arm64)
Источник Скорость
pypi.org (индекс) 32 KB/s
files.pythonhosted.org (файлы пакетов) 284 B/s
github.com (release asset напрямую) 22 KB/s
cloud-images.ubuntu.com 382 B/s
deb.debian.org 32 KB/s
ports.ubuntu.com (внутри VM) 26 KB/s
download.docker.com недоступен (HTTP 000); внутри VM 4 KB/s
mirrors.tuna.tsinghua.edu.cn недоступен
mirrors.aliyun.com/pypi (файлы пакетов) 1.4 MB/s (хост) / 152 KB/s (внутри VM)
mirrors.ustc.edu.cn/ubuntu-cloud-images 28 MB/s
mirrors.ustc.edu.cn/ubuntu-ports (внутри VM) 1.95 MB/s
mirrors.ustc.edu.cn/debian 435 KB/s
ghfast.top (прокси GitHub) 2.5 MB/s
gh-proxy.com (прокси GitHub) 1.5 MB/s
dockerproxy.net (прокси Docker Hub) Работает (сразу отдаёт manifest)

При замерах не путайте страницу индекса с файлами пакетов: страница mirrors.aliyun.com/pypi/simple/ выдаёт 7.4 MB/s, а настоящий wheel на 95.9 MB — всего 1.4 MB/s (внутри VM 152 KB/s — пользовательская сеть colima теряет на этом). Оценивайте время по цифрам для файлов пакетов.

Подмена 1 — образ VM для colima. colima использует не обычный Ubuntu cloud image, а свой кастомный образ с предустановленным docker (release asset из abiosoft/colima-core), поэтому при поднятии VM не нужно ставить docker через apt, а значит обходится недоступный download.docker.com. Скачайте его сами и скормите через --disk-image:

A=https://github.com/abiosoft/colima-core/releases/download/v0.9.0-2/ubuntu-24.04-minimal-cloudimg-arm64-docker.qcow2
mkdir -p ~/.colima/images
curl -sSL -C - -o ~/.colima/images/colima-arm64-docker.qcow2 "https://ghfast.top/$A"
# Проверка: digest берём из GitHub API. Не пропускайте — это будет запускаться как VM
curl -sSL https://api.github.com/repos/abiosoft/colima-core/releases/tags/v0.9.0-2 \
  | python3 -c "import json,sys;[print(a['digest'],a['name']) for a in json.load(sys.stdin)['assets'] if a['name'].endswith('arm64-docker.qcow2')]"
shasum -a 256 ~/.colima/images/colima-arm64-docker.qcow2

colima start --disk-image ~/.colima/images/colima-arm64-docker.qcow2 \
             --cpu 4 --memory 6 --disk 20

Прокси рвёт поток на середине (наблюдался curl 56), -C - докачивает — просто перезапустите несколько раз.

Подмена 2 — apt внутри VM. Даже с образом, где docker предустановлен, boot-скрипт lima 30-install-packages.sh всё равно один раз запустит apt-get update, чтобы поставить rsync — и полезет в ports.ubuntu.com (26 KB/s) и download.docker.com (4 KB/s), залипая на десятки минут.

Что делать (сначала прочитайте /mnt/lima-cidata/boot.sh, потом действуйте: на упавший boot-скрипт он реагирует лишь WARNING + CODE=1 и продолжает, а в конце обязательно пишет /run/lima-boot-done, так что уронить этот шаг безопасно):

export LIMA_HOME=~/.colima/_lima
limactl shell colima -- sudo sh -c '
  cat > /etc/apt/sources.list.d/ubuntu.sources <<EOF
Types: deb
URIs: https://mirrors.ustc.edu.cn/ubuntu-ports/
Suites: noble noble-updates noble-backports noble-security
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
  sed -i "s|https://download.docker.com|https://mirrors.ustc.edu.cn/docker-ce|g" \
      /etc/apt/sources.list.d/docker.list
  pkill -f "apt-get update"          # boot.sh доработает до конца и запишет метку завершения
'
# colima start после этого нормально завершается; затем доставляем rsync (теперь 1.95 MB/s)
limactl shell colima -- sudo sh -c 'apt-get update -q && apt-get install -y -q rsync'

Заодно добавьте 127.0.0.1 lima-colima в /etc/hosts внутри VM, чтобы убрать пачку предупреждений sudo: unable to resolve host.

Подмена 3 — базовый образ. docker/build сначала тянет python:3.13-slim с dockerproxy.net и делает retag, чтобы FROM в Dockerfile попал в локальный образ. По замерам dockerproxy.net сразу отдаёт manifest (HTTP 200); docker.1ms.run / docker.m.daocloud.io отвечают 401, hub.rat.dev — 302, docker.xuanyuan.me — 403.

Подмена 4 — apt и pip внутри контейнера. --build-arg APT_MIRROR=mirrors.ustc.edu.cn (deb.debian.org 32 KB/s → USTC 435 KB/s), --build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/. Обратите внимание: PIP_INDEX_URL одновременно является переменной окружения, которую pip понимает сам, поэтому достаточно объявить ARG, и pip внутри RUN её прочитает — явно писать --index-url не нужно.

Разовая подготовка (при описанных выше условиях сети) занимает примерно 25 минут, львиная доля — образ VM на 364 MB и wheel SDK на 95.9 MB. После этого flowerbox стартует за секунды.

2. plugin

Что такое plugin и как SDK его загружает

Это пакет доменных возможностей, который едет вместе с репозиторием. Код фреймворка не содержит никаких доменных знаний, они целиком лежат в каталоге plugin/ в корне репозитория и вместе с кодом клонируются, ревьюятся и тегируются.

Со стороны SDK всё подключение — две строки в build_options() в flower/core/agent.py:

PLUGIN_DIR = Path(__file__).resolve().parent.parent.parent / "plugin"
...
if use_plugin and PLUGIN_DIR.is_dir():
    opts["plugins"] = [{"type": "local", "path": str(PLUGIN_DIR)}]

В связке с setting_sources=[] (о нём отдельно ниже) именно это позволяет flower одновременно быть переносимым и «понимать вашу предметную область»: он не спрашивает, что установлено на хосте, и знает только один каталог, приехавший вместе с репозиторием.

Раскладка каталогов

Путь Что лежит Когда срабатывает Кто решает
plugin/.claude-plugin/plugin.json Идентичность пакета: name, description, version, author Читается один раз при загрузке
plugin/skills/<name>/SKILL.md Доменные знания, подгружаются по необходимости Вероятностно — используется, если модель сочтёт релевантным Модель
plugin/agents/<name>.md subagent, отдельное окно контекста Модель делегирует либо явно указано в workflow Модель / вы
plugin/hooks/hooks.json Перехват вызовов инструментов Детерминированно — совпало, значит выполняется Код
plugin/.mcp.json Подключение внешних инструментов Регистрируются как инструменты, наравне со встроенными Модель

Разница между вероятностным и детерминированным — это ключ к выбору, а не разница формулировок:

  • skill — это знание, которое лежит рядом. Модель видит его description и читает, только если сочтёт релевантным текущей задаче. Оценку релевантности делает модель, поэтому один и тот же запрос в двух прогонах может один раз его использовать, а другой — нет.
  • hook — это код. Событие совпало — он выполняется, вне зависимости от того, хочет ли модель и знает ли она о нём. Собственные сброс на диск и изоляция во flower — именно hook'и, ровно потому что они не могут работать «иногда».

Значит, критерий один: должно ли это происходить каждый раз? Должно — пишите hook. Просто «знать полезно» — пишите skill. Оформить обязательное как skill — значит поставить дисциплину в зависимость от одного решения модели.

Сейчас в репозитории в plugin/ лежат только две вещи: .claude-plugin/plugin.json и skills/example/SKILL.md. agents/, hooks/, .mcp.json пока не существуют — нужны, создавайте сами, имена каталогов ровно как в таблице выше.

Пишем skill: полный пример

Возьмём «генерацию release notes» — от нуля до подтверждения, что оно подключилось.

Шаг 1: создаём каталог. Имя каталога и есть имя skill, оно должно совпадать с name во frontmatter.

mkdir -p plugin/skills/release-notes

Шаг 2: пишем plugin/skills/release-notes/SKILL.md. Имя файла обязано быть SKILL.md, заглавными. Формат — YAML frontmatter плюс тело в Markdown, во frontmatter два поля:

Поле Назначение
name Идентификатор skill. Совпадает с именем каталога
description Модель выбирает его, глядя только на эту строку. Пишите «когда это использовать», а не «что это такое»

Минимальный файл, который сразу работает:

---
name: release-notes
description: Использовать при подготовке release notes. Применять, когда пользователь говорит «напиши release notes», «что изменилось в этой версии», «выпуск».
---

# Release notes

## Откуда брать материал

```bash
git describe --tags --abbrev=0        # предыдущий tag
git log --oneline <предыдущий tag>..HEAD   # коммиты этой версии
```

## Формат вывода

Делим на три раздела, каждый — маркированный список, каждый пункт в одну строку; пишем изменения, заметные пользователю, внутренние рефакторинги не пишем:

- **Добавлено** — что эта версия умеет из того, чего раньше не умела
- **Исправлено** — что починили, одним предложением про симптом
- **Несовместимости** — что придётся править руками при обновлении. Нечего писать — раздел не пишем вовсе

## Границы

- Номер версии не выдумывать, брать из `version` в `pyproject.toml`.
- Если неясно, заметен ли конкретный коммит пользователю, — выписать и спросить, а не решать за пользователя.

Для тела жёсткого формата нет — это просто текст, который попадёт в контекст. Ориентируйтесь на plugin/skills/example/SKILL.md: описать когда использовать, шаги, каким должен быть результат, где границы — полезнее, чем сваливать в кучу фоновые знания.

Шаг 3: убеждаемся, что он подключился. Проверяем ровно одну определённую вещь — есть каталог или нет:

cd /path/to/flower
python3 -c "from flower.core.agent import PLUGIN_DIR; print(PLUGIN_DIR, PLUGIN_DIR.is_dir())"

Только вывод /path/to/flower/plugin True означает, что тот if в build_options() сработает. Вывод False означает, что не подключилось, и в рантайме ошибки не будет — см. предупреждение ниже.

Не считайте проверкой «запустить запрос и посмотреть, вызвался ли skill example». skill вероятностен: если модель его не вызвала, это может значить и что он не установлен, и что она просто не сочла его нужным для задачи — этот сигнал две ситуации не различает. Кроме того, build_options() никогда не выставляет опцию SDK уровня сессии skills=, и попадут ли skill'ы из plugin в список, доступный координатору, на практике не проверялось. Значение True/False у PLUGIN_DIR определённо — пользуйтесь им.

Чтобы поимённо включить конкретному исполнителю несколько skill, используйте worker(..., skills=[...]) (flower/core/roles.py); имя берётся из name в SKILL.md, SDK также принимает квалифицированную форму имя_плагина:имя_skill.

В установленном flower нет plugin/ — ни при одном из трёх способов установки

PLUGIN_DIR — это три уровня вверх от flower/core/agent.py и затем plugin/. При запуске из checkout исходников это plugin/ в корне репозитория; но в wheel пакуется только каталог flowerpyproject.toml [tool.hatch.build.targets.wheel] packages = ["flower"]), и после установки в site-packages site-packages/plugin не существует, PLUGIN_DIR.is_dir() ложно — шаг молча пропускается, без ошибки и без предупреждения.

Это не проблема контейнера, охват гораздо шире. Каждый путь в install.shuv tool install, pipx install, самонастройка uv с последующим uv и запасной pip install --user — ставит wheel. То есть у flower, установленного одной командой, пакет доменных возможностей всегда молча не работает. Контейнер — лишь один частный случай той же проблемы: docker/Dockerfile делает COPY только pyproject.toml, flower/, examples/, а plugin/ в образ не попадает.

Зафиксировано в issue #15. После установки первым делом прогоните команду с PLUGIN_DIR выше: вывод False означает, что в этой установке пакета доменных возможностей нет. Если он нужен, сейчас остаётся только запуск из checkout исходников.

Почему setting_sources=[] вынуждает доменные возможности идти через plugin

В той же функции есть ещё одна строка:

"setting_sources": [] if portable else ["project"],

По умолчанию в SDK стоит None = читать все три источника: ~/.claude/settings.json (пользовательский), .claude/settings.json (проектный), .claude/settings.local.json (локальный). flower по умолчанию передаёт [] и отключает их все.

Читается ли Последствие
~/.claude/ (хост) Нет На другой машине поведение то же, результат не меняется из-за «а у меня тут настроено»
Проектный .claude/ Нет То, что лежит в .claude/skills/, .claude/agents/, под flower не работает вообще
plugin/ Да Путь зашит в коде, едет вместе с репозиторием
Учётные данные Не по этому каналу Обязателен собственный .env; блоки env из ~/.claude/settings.json и settings.local.json служат только последним запасным вариантом и дают лишь 9 ключей учётных данных, см. конфигурацию

То, что .claude/ не работает, — не забытая настройка, а определение этого ограничения: стоит прочитать с хоста хотя бы один байт, и «на другой машине поведение то же» перестаёт быть правдой. Поэтому у доменных возможностей остаётся ровно один канал — plugin/, который едет с репозиторием.

Два переключателя (оба у build_options(), значения по умолчанию — переносимый вариант):

Параметр По умолчанию Что будет, если поменять
portable True Передать Falsesetting_sources становится ["project"], начинает читаться проектный .claude/ (со стороны SDK: чтобы читать CLAUDE.md, обязателен "project"). Переносимость при этом теряется
use_plugin True Передать Falseplugin/ не подключается вовсе, доменные возможности целиком на AgentSpec.instructions

Попутно: instructions идёт через append (append у system_prompt), и это отдельный от plugin канал — первый присутствует в контексте каждый раунд, второй подгружается по необходимости. Короткие обязательные правила — в instructions, длинные и изредка нужные знания — в skill.

3. Сайт документации

Сайт, который вы читаете, собран на mkdocs-material, исходники лежат в docs/ репозитория, push в main публикует автоматически.

Этап Что это
Конфигурация mkdocs.yml, docs_dir: docs
Многоязычность mkdocs-static-i18n, docs_structure: folderdocs/zh/, docs/en/… язык по умолчанию zh
Зависимости docs-requirements.txt (версии зафиксированы). Не extra docs из pyproject.toml — CI ставит именно первое
Сборка mkdocs build --strict. Битая внутренняя ссылка или nav на несуществующую страницу сразу роняют сборку, молча выложить 404 не получится
Редиректы hooks/redirects.py, после сборки пишет заглушки с meta-refresh по итоговым URL, связывая старые плоские адреса (/start/, /workflow/, /case-ht001/…) с новыми местами
Деплой .github/workflows/docs.ymlactions/upload-pages-artifact@v3 + actions/deploy-pages@v4, публикация в GitHub Pages

Правки документации локально:

pip install -r docs-requirements.txt
mkdocs serve                  # локальный предпросмотр
mkdocs build --strict         # прогнать перед коммитом, та же команда, что и в CI

CI запускается при push в main и если изменения попали в эти пути; кроме того, можно вручную запустить workflow_dispatch на странице Actions:

docs/**  mkdocs.yml  hooks/**  docs-requirements.txt  install.sh  .github/workflows/docs.yml

Почему install.sh публикуется с Pages

В конце шага сборки есть строка:

- run: cp install.sh site/install.sh

Скрипт установки кладётся в артефакт сайта и таким образом висит на домене документации, а установка одной командой выглядит так:

curl -fsSL https://chenyuheee.github.io/flower/install.sh | sh

Причина сугубо практическая: raw.githubusercontent.com из Китая недоступен, а *.github.io доступен (проверено). Сам скрипт лежит в корне репозитория, при публикации просто копируется — не нужно поддерживать две копии содержимого и не нужен отдельный CDN.

Что делает сам install.sh: выбирает установщик Python-инструментов (uv > pipx > поставить uv > pip --user), ставит flower с GitHub и подсказывает следующий шаг. Учётных данных он не трогает — первый запуск flower спросит их сам и сохранит в ~/.config/flower/.env.