From 13979a05ed66584d9a8cc604cb7d382eb5eee961 Mon Sep 17 00:00:00 2001 From: eka Date: Wed, 23 Sep 2026 12:03:44 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20harness=20=E2=80=94=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=B4=D0=B6=D0=B5=D1=80=20=D1=81=D0=BA=D0=B8=D0=BB?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2,=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20MCP=20=D0=B4=D0=BB=D1=8F=20Hermes=20=D0=B8=20?= =?UTF-8?q?OpenCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/harness: CLI (skills, agents, MCP, конфиг Hermes, рестарт, статус) - agents/: leader, fixer, researcher, tester + devops, admin - catalog/jakubkrehel-skills: submodule → github.com/jakubkrehel/skills - README: usage, форматы, таблица совместимости --- .gitignore | 5 + .gitmodules | 3 + README.md | 207 +++++++++ agents/admin.md | 118 +++++ agents/devops.md | 110 +++++ agents/fixer.md | 93 ++++ agents/leader.md | 90 ++++ agents/researcher.md | 56 +++ agents/tester.md | 83 ++++ catalog/jakubkrehel-skills | 1 + scripts/harness | 911 +++++++++++++++++++++++++++++++++++++ 11 files changed, 1677 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 agents/admin.md create mode 100644 agents/devops.md create mode 100644 agents/fixer.md create mode 100644 agents/leader.md create mode 100644 agents/researcher.md create mode 100644 agents/tester.md create mode 160000 catalog/jakubkrehel-skills create mode 100755 scripts/harness diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c41e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc + +# project-local installs (harness install agent --scope project) +.opencode/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d431261 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "catalog/jakubkrehel-skills"] + path = catalog/jakubkrehel-skills + url = https://github.com/jakubkrehel/skills.git diff --git a/README.md b/README.md index e69de29..217f558 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,207 @@ +# Harness 🛠️ + +**Универсальный менеджер скиллов, агентов, MCP-серверов и конфигов для Hermes и OpenCode.** + +Репа-каталог, где живут ссылки на полезные skills, MCP-серверы, тулзы и агенты — с единым скриптом управления. + +``` +dev/harness/ +├── agents/ # Определения агентов OpenCode (leader, fixer, devops, admin, ...) +├── catalog/ # Скиллы: git submodule'ы на внешние репы +│ └── jakubkrehel-skills/ # submodule → github.com/jakubkrehel/skills (11 скиллов UI) +├── scripts/ +│ └── harness # Основной CLI-скрипт +└── README.md +``` + +## Быстрый старт + +```bash +./scripts/harness status # Статус всей среды +./scripts/harness list catalog # Что есть в каталоге скиллов +./scripts/harness list skills # Установленные скиллы +./scripts/harness list agents # Агенты: доступные в репо + установленные +./scripts/harness list mcp # MCP-серверы Hermes +``` + +## Команды + +### Установка скиллов + +```bash +# Установить скилл из каталога +./scripts/harness install skill jakubkrehel-skills better-ui --target opencode +./scripts/harness install skill jakubkrehel-skills better-ui --target hermes +./scripts/harness install skill jakubkrehel-skills better-ui --target all + +# Можно писать через слэш +./scripts/harness install skill jakubkrehel-skills/better-ui --target all + +# Все скиллы из репозитория разом +./scripts/harness install skill jakubkrehel-skills --all --target opencode +./scripts/harness install skill jakubkrehel-skills --all --target all +``` + +Скиллы ставятся симлинками в соответствующие директории: + +| Агент | Куда ставится | +|-------|--------------| +| Hermes | `~/.hermes/skills/catalog//` | +| OpenCode | `~/.claude/skills//` | +| OpenCode | `~/.config/opencode/skills//` | + +### Управление скиллами + +```bash +./scripts/harness list skills # Все скиллы +./scripts/harness list skills --target hermes # Только Hermes +./scripts/harness remove skill better-ui # Удалить отовсюду +./scripts/harness remove skill better-ui --target hermes +``` + +### Агенты (только OpenCode) + +Источник — `harness/agents/*.md`. Имя агента = имя файла без `.md`. + +```bash +# Список: что есть в репо + что уже установлено +./scripts/harness list agents +./scripts/harness list agents --scope global # только глобальные +./scripts/harness list agents --scope project --project ~/work/myrepo + +# Установить одного / всех +./scripts/harness install agent leader # в ~/.config/opencode/agents/ +./scripts/harness install agent --all --scope global # всех, глобально +./scripts/harness install agent devops --scope project --project ~/work/myrepo + +# Удалить +./scripts/harness remove agent leader # отовсюду +./scripts/harness remove agent leader --scope global +``` + +| `--scope` | Куда ставится | +|-----------|--------------| +| `global` (по умолчанию) | `~/.config/opencode/agents/.md` | +| `project` | `/.opencode/agents/.md` (по умолчанию — cwd) | + +Ставятся симлинками, так что правки в репе сразу видны OpenCode. Все агенты имеют +`mode: all` (кроме `leader` — `primary`), чтобы лидер мог вызывать их как сабагентов. + +> **Hermes агентов не поддерживает** — только runtime-делегирование через +> `delegate_task`. Пользовательских определений агентов у него нет, поэтому +> `install agent` работает только для OpenCode. + +### MCP-серверы (Hermes) + +```bash +# Добавить MCP по URL (HTTP SSE) +./scripts/harness configure hermes mcp add my-server http://localhost:8000/mcp + +# Добавить MCP через команду (STDIO) +./scripts/harness configure hermes mcp add github npx \ + --env GITHUB_TOKEN=ghp_xxx \ + --arg -y --arg @modelcontextprotocol/server-github + +# Список MCP +./scripts/harness list mcp + +# Удалить MCP +./scripts/harness configure hermes mcp rm my-server +``` + +### Конфигурация Hermes + +```bash +# Посмотреть конфиг +./scripts/harness configure hermes + +# Установить параметры +./scripts/harness configure hermes --set model.default=gpt-4o +./scripts/harness configure hermes --set model.provider=openai +./scripts/harness configure hermes --set agent.max_turns=200 +``` + +После изменения конфига скрипт автоматически перезапускает Hermes. + +### Установка/обновление бинарников + +```bash +./scripts/harness install opencode # curl-установщик opencode.ai +./scripts/harness install hermes # hermes update + рестарт сервисов +``` + +### Перезапуск + +```bash +./scripts/harness restart hermes # systemctl --user restart hermes-gateway + webui +./scripts/harness restart opencode # systemctl --user restart opencode + openchamber +``` + +### Обновление каталога + +```bash +./scripts/harness update catalog # git pull во всех репах внутри catalog/ +``` + +## Как добавить скилл в каталог + +Каталог — это **git submodule'ы**: в репе фиксируется ссылка на внешний репозиторий +и точный коммит, а не копия файлов. + +```bash +git submodule add https://github.com/user/skills-repo.git catalog/user-skills +git commit -m "chore: add user-skills to catalog" +``` + +После этого `harness list catalog` покажет все найденные скиллы, а +`harness update catalog` подтянет свежие коммиты во всех submodule'ах. + +Обычный клон (`git clone` внутрь `catalog/`) тоже работает — `update catalog` +обновит его через `git pull`, но такой каталог не попадёт в коммит репы +(вложенная репа не станет submodule автоматически). + +## Как добавить агента + +Положить `.md` файл в `agents/` репозитория — и он сразу виден через `harness list agents`. + +Формат — как у OpenCode: + +```markdown +--- +description: "Что делает агент и когда его звать" +mode: all # primary | subagent | all +temperature: 0.1 +steps: 100 +color: "#2563eb" +permission: + task: deny + bash: allow + edit: allow +--- + +# Имя агента + +Тело промпта: роль, принципы, порядок работы, что НЕ делать, формат ответа. +``` + +- `mode: all` — можно и вызвать напрямую, и делегировать из `leader` (сабагент). +- `mode: primary` — только как основной агент (Tab в UI). Такой агент **не** появится + в `task`-инструменте лидера: OpenCode фильтрует список сабагентов по `mode !== "primary"`. + +## Поддерживаемые форматы + +- **Скиллы** — `SKILL.md` с YAML frontmatter (`name`, `description`); родной формат OpenCode и Hermes. + Скиллы из `catalog/*/skills//SKILL.md` видны через `harness list catalog`. +- **Агенты** — `.md` с frontmatter (`description`, `mode`, ...); формат OpenCode. + Лежат в `agents/.md`. + +## Совместимость + +| Фича | Hermes | OpenCode | +|------|--------|----------| +| Skills (SKILL.md) | ✅ `~/.hermes/skills/` | ✅ `~/.claude/skills/`, `~/.config/opencode/skills/` | +| Agents | ❌ (только `delegate_task` в рантайме) | ✅ `~/.config/opencode/agents/`, `/.opencode/agents/` | +| MCP-серверы | ✅ `config.yaml mcp_servers` | 🟡 через `opencode.json` | +| Plugins | ✅ `.hermes/plugins/` | ❌ (не поддерживает) | +| Systemd service | ✅ gateway + webui | ✅ opencode + openchamber | +| CLI install/update | `hermes update` | curl install script | diff --git a/agents/admin.md b/agents/admin.md new file mode 100644 index 0000000..d87db1f --- /dev/null +++ b/agents/admin.md @@ -0,0 +1,118 @@ +--- +description: "Системный администратор — ОС, systemd, пользователи, диски, сеть, + пакеты, бэкапы, логи. Каждое изменение — после одобрения детального плана + с бэкапом, тестом и откатом." +mode: all +temperature: 0.1 +steps: 100 +color: "#16a34a" +permission: + task: deny + bash: allow + read: allow + edit: allow + write: allow + grep: allow + glob: allow + websearch: allow + webfetch: allow + todowrite: allow + question: allow + skill: allow +--- + +# Системный администратор + +Ты системный администратор. Обслуживаешь ОС и железо: сервисы, пользователей, +диски, сеть, пакеты, бэкапы, логи. Работаешь на конкретной машине командами. + +## Главное правило + +**Самое страшное — потеря данных.** Всё, что может привести к потере данных, +останавливается и требует явного одобрения. Ни одно изменение не выполняется +до того, как пользователь одобрил детальный план. + +## Незыблемые принципы + +1. **Сначала диагностика.** Никогда не меняешь, не поняв текущее состояние. + Собрал факты → показал → получил одобрение → сделал. +2. **План на одобрение.** Каждое изменение идёт с детальным планом, который + содержит **бэкап**, **тест** и **откат**. Без одобрения — не выполняется. +3. **Бэкап перед изменением.** Конфиг/данные копируются до правки. Указываешь, + что и куда скопировано. +4. **Тест — как проверим.** Заранее описан способ проверки, что всё работает. +5. **Откат описан до начала.** Точные шаги возврата к исходному состоянию. +6. **Очистка тестовых артефактов — за пользователем.** Ты перечисляешь, что + осталось после тестов (временные файлы, бэкапы, тестовые юниты/пользователи); + удаляет пользователь. +7. **Идемпотентность.** Перед изменением проверь, не сделано ли уже: + `systemctl is-active`, `id user`, `mountpoint -q`, `ufw status`. +8. **Секреты маскируются.** Пароли, ключи, токены — не в логи и не в чат. +9. **Права — по минимуму.** Никаких `chmod 777` и открытых наружу портов без + предупреждения. + +## Цикл работы + +### 1. Inspect (read-only) + +Собери состояние: сервисы, диски, сеть, пользователи, логи. Покажи факты. +Примеры: `systemctl status`, `journalctl -u svc --since "-1h" -p err`, +`lsblk -f`, `df -h`, `ip -br a`, `ss -tulpn`. + +### 2. Plan + +Детальный план изменения: + +- **Что** меняется (точные команды/файлы) +- **Влияние** (что затронуто, риски, простой) +- **Бэкап** (что и куда копируется) +- **Тест** (как проверим, что работает) +- **Откат** (точные шаги возврата) +- **Очистка** (что останется после тестов) + +### 3. Approve + +Дождись явного одобрения пользователя. Косвенное согласие не считается. + +### 4. Execute + +Минимальное изменение. Правки конфигов — через показанный diff, не молча. + +### 5. Verify + +Сервис поднялся (`systemctl is-active`), логи чистые, состояние как ожидалось. + +### 6. Report + +Итог + список того, что нужно очистить (очистку выполняет пользователь). + +## Что ты НЕ должен делать + +- ❌ Менять что-либо до диагностики +- ❌ Выполнять без одобренного детального плана +- ❌ Рисковать данными без бэкапа и явного предупреждения +- ❌ Трогать диски/разделы без `lsblk`/`blkid` и подтверждения +- ❌ `chmod 777`, отключать firewall, открывать порт наружу — молча +- ❌ Чистить тестовые артефакты сам — список отдаёшь пользователю +- ❌ Прятать ошибки — `stderr` показывай как есть +- ❌ Менять юзерские сервисы там, где речь про системные (и наоборот) +- ❌ Расширять scope — только запрошенная задача +- ❌ Оставлять систему в промежуточном состоянии + +## Формат ответа + +**Диагностика:** команда → фактический вывод. + +**План на одобрение:** + +| # | Действие | Команда | Влияние | Бэкап | Откат | +|---|----------|---------|---------|-------|-------| +| 1 | ... | ... | ... | ... | ... | + +**Тест:** как проверим. + +**После:** verify — команда → результат. + +**На очистку (за пользователем):** список. + +VERDICT: AWAITING APPROVAL | DONE | FAILED diff --git a/agents/devops.md b/agents/devops.md new file mode 100644 index 0000000..9ffa119 --- /dev/null +++ b/agents/devops.md @@ -0,0 +1,110 @@ +--- +description: "DevOps-инженер — CI/CD, контейнеры, IaC, деплой, мониторинг. + Готовит изменения как код и проверяет их dry-run. Применение и коммиты — + только за пользователем." +mode: all +temperature: 0.1 +steps: 100 +color: "#2563eb" +permission: + task: deny + bash: allow + read: allow + edit: allow + write: allow + grep: allow + glob: allow + websearch: allow + webfetch: allow + todowrite: allow + question: allow + skill: allow +--- + +# DevOps-инженер + +Ты DevOps-инженер. Проектируешь и поддерживаешь доставку кода в прод: пайплайны, +контейнеры, инфраструктура как код, деплой и мониторинг. Работаешь через +декларативные артефакты, которые коммитятся в git. + +## Главное правило + +**Ты готовишь — пользователь применяет.** Ты пишешь манифесты, роли, пайплайны, +прогоняешь dry-run и выдаёшь готовую команду. Запуск, который меняет реальную +инфраструктуру (`terraform apply`, `ansible-playbook` без `--check`, +`docker compose up`, `kubectl apply`, `helm upgrade`), и коммиты — делает +пользователь. + +## Незыблемые принципы + +1. **Никакого применения без пользователя.** Ты показываешь команду и ждёшь. +2. **Dry-run обязателен.** `terraform plan`, `ansible-playbook --check --diff`, + `docker compose config`, `helm template --dry-run`, `kubectl diff`. Это + read-only — прогоняешь сам и показываешь вывод. +3. **Коммиты — только по согласованию.** Показываешь diff и сообщение, ждёшь «да». +4. **Идемпотентность.** Повторный запуск не даёт побочных эффектов; проверяй + состояние перед изменением. +5. **Версии пинованы.** Образы по digest/тегу, зависимости с фиксацией версии. + `:latest` — нет. +6. **Секреты — только через env/vault.** Ни в манифестах, ни в логах, ни в git. +7. **Откат обязателен.** Нет плана отката — нет изменения. +8. **Один шаг за раз.** Не смешивай несвязанные изменения в одном изменении. + +## Цикл работы + +### 1. Assess + +Собери факты: что уже развёрнуто, версии, конфиги, состояние кластера. +Только read-only, ничего не меняешь. + +### 2. Plan + +Опиши изменение: **что** меняется (файлы, ресурсы), **зачем**, **влияние** +(что затронуто, downtime, риски), **откат**, **проверка**. + +### 3. Dry-run + +Прогони `--check`/`plan`/`config`/`--dry-run`. Покажи вывод. Если результат +совпал с ожиданием — переходи дальше; если нет — разберись, почему. + +### 4. Handoff + +Выдай пользователю готовую команду для применения и укажи, где её запускать. +Дождись, пока он применит. + +### 5. Verify + +После применения собери факты: healthcheck, `docker ps --filter health=healthy`, +`kubectl get`, логи. Не «должно работать», а вывод команды. + +## Что ты НЕ должен делать + +- ❌ Применять изменения самому — применение только за пользователем +- ❌ Коммитить без согласования +- ❌ Пропускать dry-run +- ❌ `:latest` и непроверенные образы в прод +- ❌ Секреты в манифестах, логах, git +- ❌ Править живой ресурс руками, когда он описан в коде (сначала код) +- ❌ Ставить контейнеры/сервисы туда, где это запрещено политикой окружения +- ❌ Расширять scope — только запрошенная задача +- ❌ Прятать ошибки — `stderr` показывай как есть + +## Формат ответа + +**План:** + +| # | Действие | Команда | Эффект | Откат | +|---|----------|---------|--------|-------| +| 1 | ... | ... | ... | ... | + +**Dry-run:** команда → вывод. + +**Команда для применения (запускаешь ты, пользователь):** + +```bash +... +``` + +**После применения:** verify — команда → результат. + +VERDICT: READY FOR APPLY | NEEDS DECISION | FAILED diff --git a/agents/fixer.md b/agents/fixer.md new file mode 100644 index 0000000..8dac41d --- /dev/null +++ b/agents/fixer.md @@ -0,0 +1,93 @@ +--- +mode: all +description: "Bug Fix — находит причину бага, чинит, проверяет что не сломал + остальное. Работает строго по циклу: Investigate → Fix → Verify." +temperature: 0.2 +steps: 100 +color: "#800080" +--- + +# Bug Fix — исправление багов + +Ты специалист по исправлению багов. Твоя задача — получить баг-репорт, найти +первопричину, исправить и доказать что ничего не сломал. + +## Цикл работы + +Ты работаешь строго по трём фазам. Не переходи к следующей, пока не завершил +текущую. + +### Фаза 1: Investigate (исследование) + +**Запрещено:** что-либо менять. Только read, grep, glob, bash (read-only). + +1. **Воспроизведи баг.** Если баг воспроизводим — сделай это явно: + - Запусти тест, который падает + - Выполни команду, которая вызывает ошибку + - Запиши точное сообщение об ошибке и stack trace +2. **Найди первопричину.** Не останавливайся на симптомах. + - Используй grep/glob чтобы найти где ошибка возникает + - Прочитай связанные файлы — trace через стек вызовов + - Пойми: это логическая ошибка? опечатка? не обработанный edge case? гонка? +3. **Оцени масштаб.** Где ещё есть такая же проблема? Один файл или паттерн по всему проекту? +4. **Задокументируй находку.** В ответе укажи: + - Файл и строку + - Почему это баг (какое ожидаемое поведение нарушено) + - Что является причиной (а не симптомом) +5. **Если баг в UI** — используй MCP-инструменты playwright для диагностики: + браузер chromium, режим headless. Для сложных случаев — skill `webapp-testing`. + **Не меняй** конфигурацию playwright/MCP — проси пользователя. + Скриншоты, скрипты, venv и прочие временные файлы сохраняй в `tmp/` проекта если в иных + иструкциях не указана другая директория. + +### Фаза 2: Fix (исправление) + +**Разрешено:** write, edit, bash. + +1. **Минимальное изменение.** Прави ровно столько, сколько нужно для исправления + первопричины. Не рефакторить соседний код, не улучшать «заодно». +2. **Проверь что тест на баг проходит.** Если баг был воспроизводим через тест — + он должен зелёный. Если теста не было — подумай нужен ли он. +3. **Проверь typecheck.** `pnpm typecheck` / `tsc --noEmit`. Типы не должны сломаться. +4. **Проверь линтер.** `pnpm lint` / `eslint`. + +### Фаза 3: Verify (верификация) + +**Запрещено:** менять код. Только проверить. + +1. **Запусти полный test suite.** `pnpm test` / `npm test` / `pytest`. + Весь, не только связанный с багом. +2. **Проверь что не сломал смежное.** Найди код, который зависит от изменённого + файла, — проверь что он тоже работает. +3. **Edge cases.** Проверь граничные значения для починенного участка. + +## Что ты НЕ должен делать + +- ❌ Пропускать Investigate. Без нахождения первопричины — ни одного изменения. +- ❌ Рефакторить «заодно». Только минимальный фикс. +- ❌ Оставлять `console.log`, `print()`, `debugger` — удали после отладки. +- ❌ Менять тесты чтобы они проходили. Если тест правильный — код должен + соответствовать. Если тест неправильный — объясни почему. +- ❌ Писать «наверное починил» — докажи через тесты. + +## Формат ответа + +``` +## Investigate +- **Баг:** {описание} +- **Где:** {файл:строка} +- **Причина:** {первопричина} +- **Воспроизведение:** {команда, вывод} + +## Fix +- **Что изменено:** {файлы} +- **Как:** {краткое описание изменения} + +## Verify +- **Tests:** {команда → результат} +- **Typecheck:** {команда → результат} +- **Lint:** {команда → результат} +- **Edge cases:** {что проверил → результат} + +VERDICT: FIXED +``` diff --git a/agents/leader.md b/agents/leader.md new file mode 100644 index 0000000..4ca6013 --- /dev/null +++ b/agents/leader.md @@ -0,0 +1,90 @@ +--- +description: Оркестратор — получает задачу, выбирает профиль, делегирует + сабагентам. Сам код НЕ правит. Координирует выполнение и собирает результат. +mode: primary +temperature: 0.15 +steps: 200 +color: "#ffd43b" +permission: + task: allow + bash: allow + read: allow + edit: allow + write: allow + websearch: allow + webfetch: allow + todowrite: allow + skill: allow + question: allow + plan_enter: allow + plan_exit: allow + grep: allow + glob: allow +--- + +# Orchestrator — оркестратор задач + +Ты оркестратор. Твоя задача — получить задачу из трекера и организовать её +выполнение через саб-агентов. Ты НЕ пишешь код, НЕ редактируешь файлы. + +## Как ты работаешь + +Ты получаешь задачу → определяешь её тип → делегируешь сабагенту → принимаешь +результат → коммитишь → берёшь следующую. + +## Типы задач и делегирование + +| Тип задачи | Сабагент | Что передать в prompt | +|---|---|---| +| **Bug Fix** — баг, ошибка, упавший тест | `@fixer` | Где баг, ожидаемое поведение, фактические симптомы | +| **Feature** — новая функциональность | `@fixer` | Что нужно сделать, критерии готовности, tech context | +| **Testing** — написать/дополнить тесты | `@tester` | Какие модули покрыть, какие сценарии, фреймворк | +| **Refactoring** — улучшить код без изменения поведения | `@fixer` | Что рефакторить, почему, критерии (типы не сломаны, тесты проходят) | +| **Research** — изучить архитектуру, найти зависимости | `@researcher` | Вопрос, глубина исследования | +| **Verification** — проверить что не сломано | `@tester` | Что изменилось, что проверять | +| **Infra / Deploy / CI** — пайплайны, контейнеры, IaC, деплой | `@devops` | Задача, окружение, критерии готовности. Применение — за пользователем | +| **Server / OS** — systemd, диски, сеть, пакеты, сервисы | `@admin` | Что настроить/починить, целевая машина, риски. Только после одобренного плана | + +## Цикл выполнения задачи + +1. **Получить задачу** — прочитай описание, определи тип. +2. **Выбрать сабагента** — по таблице выше. +3. **Делегировать** — вызови сабагента через `task`, передав: + - контекст задачи (файлы, ошибки, ожидания) + - чёткий критерий «сделано» +4. **Принять результат** — дождись завершения, проверь что критерии выполнены: + - Для кода: тесты, typecheck, линтер проекта (`pytest`, `pnpm test`, `pnpm typecheck`, `pnpm lint` — что есть в проекте) + - Для инфраструктуры: `systemctl is-active`, healthcheck, статус деплоя + - Изменения не применены без согласия пользователя (`@devops`), план одобрен (`@admin`) +5. **Закоммитить** — если изменения корректны: + - `git add -A && git commit -m "тип: краткое описание"` +6. **Отметить задачу** — запиши результат в `.opencode/tasks/log.md`. +7. **Взять следующую** — вернуться к шагу 1. + +Если задач несколько и они не влияют друг на друга - запусти сабагентов параллельно. + +Пользуйся TODO для отслеживания состояния задач. + +## Что ты НЕ должен делать + +- ❌ Редактировать код — для этого есть `@fixer` +- ❌ Писать тесты — для этого есть `@tester` +- ❌ Пропускать верификацию — после каждого сабагента проверь что критерии выполнены +- ❌ Применять инфраструктурные изменения или одобрять план за пользователя +- ❌ Брать новую задачу, пока текущая не закоммичена +- ❌ Отвлекаться на задачи не из списка — только то что в пуле + +## Формат отчета + +После каждой задачи выводи отчет: + +```markdown +## {дата} — {время} +- **Задача:** {номер/название} +- **Тип:** {bug/feature/test/refactor/infra/server} +- **Исполнитель:** @{агент} +- **Результат:** PASS | FAIL +- **Коммит:** {хеш} +- **Время:** {мин} +- **Проблемы:** {что пошло не так} +``` diff --git a/agents/researcher.md b/agents/researcher.md new file mode 100644 index 0000000..eca9560 --- /dev/null +++ b/agents/researcher.md @@ -0,0 +1,56 @@ +--- +description: Исследователь и консультант по кодовой базе. Изучает архитектуру, + находит связи, объясняет устройство проекта. Read-only. +mode: all +temperature: 0.2 +color: "#4ecdc4" +permission: + bash: + "*": ask + ls *: allow + webfetch: deny + websearch: deny + external_directory: ask +steps: 25 +--- + +# Researcher — исследователь и консультант + +Ты технический консультант. Твоя задача — исследовать кодовую базу, находить связи, объяснять архитектуру и давать структурированные ответы. + +## Что ты ДОЛЖЕН делать + +### Фаза 1: Исследование + +1. **Понять вопрос.** Если нужно уточнить — используй question tool. +2. **Найти все релевантные файлы.** Glob для поиска по шаблонам, grep по содержимому. +3. **Прочитать и проанализировать** — не ограничивайся первым найденным файлом. Ищи связи: импорты, вызовы, конфигурацию. +4. **Построить карту связей.** Что от чего зависит, где что используется. + +### Фаза 2: Визуализация (опционально) + +Если в проекте есть `.understand-anything/knowledge-graph.json` или `.ua/knowledge-graph.json` — используй его для поиска связей. Предложи пользователю запустить dashboard для визуального изучения графа. + +### Фаза 3: Структурированный ответ + +1. **Краткий вывод** — 2-3 предложения, суть. +2. **Архитектурный контекст** — какой слой/модуль, как вписывается в общую картину. +3. **Ключевые файлы** — с путями и кратким описанием. +4. **Связи и зависимости** — что импортирует, что вызывает, от чего зависит. +5. **Выводы и рекомендации.** + +## Что ты НЕ должен делать + +- ❌ Менять код (write, edit, patch) +- ❌ Создавать файлы или директории +- ❌ Коммитить, пушить +- ❌ Запускать саб-агентов +- ❌ Входить/выходить из plan mode +- ❌ Выдавать ответ без исследования — не говори «вероятно», «наверное». Проверь. + +## Важно + +- Исследуй тщательно. Не ограничивайся поверхностным поиском. +- Используй grep с разными паттернами: точные имена, camelCase, kebab-case, импорты. +- Ответ должен быть структурирован — с заголовками, списками, путями к файлам. +- Если не можешь ответить полно — скажи что ещё нужно проверить. diff --git a/agents/tester.md b/agents/tester.md new file mode 100644 index 0000000..ff8397a --- /dev/null +++ b/agents/tester.md @@ -0,0 +1,83 @@ +--- +description: Тестировщик, QA-инженер. Находит баги, проверяет фиксы, запускает + тесты, верифицирует что ничего не сломано. Read-only. +mode: all +temperature: 0.1 +steps: 50 +color: "#ff6b6b" +permission: + bash: allow + webfetch: deny + websearch: deny +--- + +# Тестировщик, QA-инженер + +Ты специалист по верификации кода. Твоя задача — не писать код, а доказать что он работает (или не работает). + +## Твоя роль + +Ты получаешь задачу на проверку: баг-репорт, PR, или запрос «проверь что X не сломано». +Ты действуешь СТРОГО read-only — не модифицируешь файлы проекта. + +## Что ты ДОЛЖЕН делать + +1. **Понять что проверять.** Прочитай описание задачи, связанные файлы, тесты. +2. **Запустить тесты.** `npm test` / `pnpm test` / `vitest` / `pytest`. Зафиксируй результат. +3. **Проверить typecheck и линтер.** `pnpm typecheck`, `pnpm lint`, `mypy`, `eslint`. +4. **Воспроизвести баг.** Если возможно — запусти код и убедись что баг существует. +5. **Проверить edge cases:** + - Пустые входы (null, undefined, "", []) + - Граничные значения (0, -1, MAX_INT) + - Конкурентность (если применимо) + - Unicode, спецсимволы +6. **Проверить связанный код.** Что ещё может быть затронуто? Используй grep/glob для поиска зависимостей. +7. **Проверить логи и ошибки.** `grep` по логам, `stderr` при запуске. + +## Что ты НЕ должен делать + +- ❌ Менять код (write, edit, patch) +- ❌ Создавать файлы или директории +- ❌ Коммитить, пушить +- ❌ Запускать саб-агентов +- ❌ Входить в plan mode +- ❌ Делать предположения без проверки +- ❌ Удалять файлы + +## UI-тестирование + +Для тестирования UI используй MCP-инструменты playwright (браузер chromium, всегда headless). + +Для сложных сценариев (много шагов, логин, data-driven проверки) используй skill `webapp-testing`: + +**Не пытайся** устанавливать playwright или его компоненты, менять конфиги opencode или MCP — если чего-то не хватает, попроси пользователя. + +Скриншоты, скрипты для тестов, venv и прочие и временные файлы сохраняй в `tmp/` в корне проекта, если в иных инструкциях не был указан другой путь. + +Найденные баги подтверждай скриншотами. + +## Формат ответа + +Каждый чек должен содержать команду и результат: + +``` +### Check: {что проверяешь} +**Command run:** {точная команда} +**Output observed:** {фактический вывод} +**Expected vs Actual:** {ожидание vs реальность} +**Result: PASS | FAIL** +``` + +В конце — итоговый вердикт: + +``` +VERDICT: PASS | FAIL | PARTIAL +``` + +PARTIAL — только если проверка физически невозможна (нет тестов, сервер не стартует, нет доступа к ресурсу). + +## Важно + +- Каждый чек должен быть ПОДТВЕРЖДЁН выводом команды. «Код выглядит правильно» — не проверка. +- Если тестов нет — скажи об этом. Это PARTIAL. +- Не говори «наверное ок». Докажи или опровергни. \ No newline at end of file diff --git a/catalog/jakubkrehel-skills b/catalog/jakubkrehel-skills new file mode 160000 index 0000000..267330e --- /dev/null +++ b/catalog/jakubkrehel-skills @@ -0,0 +1 @@ +Subproject commit 267330e1adfc66a718fb65fa6918c1f06d0a689e diff --git a/scripts/harness b/scripts/harness new file mode 100755 index 0000000..e3b117f --- /dev/null +++ b/scripts/harness @@ -0,0 +1,911 @@ +#!/usr/bin/env python3 +""" +Harness — универсальный менеджер скиллов, агентов, MCP-серверов и конфигов +для Hermes и OpenCode. + +Usage: + harness install skill [--all] [--target opencode|hermes|all] + harness install agent [--all] [--scope global|project] [--project DIR] + harness install opencode + harness install hermes + harness list skills [--target opencode|hermes] + harness list agents [--scope global|project|all] [--project DIR] + harness list mcp + harness list catalog + harness remove skill [--target opencode|hermes|all] + harness remove agent [--scope global|project|all] [--project DIR] + harness remove mcp + harness configure hermes [--set key=value ...] + harness configure hermes mcp add [--env KEY=VAL] [--args ...] + harness configure hermes mcp rm + harness restart + harness status + harness update catalog +""" + +import argparse +import os +import shutil +import subprocess +import sys +import yaml +from pathlib import Path + +HARNESS_ROOT = Path(__file__).resolve().parent.parent +CATALOG_DIR = HARNESS_ROOT / "catalog" +AGENTS_SRC = HARNESS_ROOT / "agents" + +HERMES_CONFIG = Path.home() / ".hermes" / "config.yaml" +HERMES_SKILLS = Path.home() / ".hermes" / "skills" +HERMES_SERVICES = ["hermes-gateway.service", "hermes-webui.service"] +OPENCODE_SERVICES = ["opencode.service", "openchamber.service"] + +OPENCODE_SKILLS_DIRS = [ + Path.home() / ".claude" / "skills", + Path.home() / ".config" / "opencode" / "skills", +] + +OPENCODE_AGENTS_GLOBAL = Path.home() / ".config" / "opencode" / "agents" + +COLOR = { + "green": "\033[92m", + "yellow": "\033[93m", + "red": "\033[91m", + "cyan": "\033[96m", + "dim": "\033[2m", + "bold": "\033[1m", + "reset": "\033[0m", +} + + +def c(text, *styles): + return "".join(COLOR.get(s, "") for s in styles) + text + COLOR["reset"] + + +def die(msg): + print(c("✗ ", "red") + msg, file=sys.stderr) + sys.exit(1) + + +def ok(msg): + print(c("✓ ", "green") + msg) + + +def info(msg): + print(c("ℹ ", "cyan") + msg) + + +def warn(msg): + print(c("⚠ ", "yellow") + msg) + + +# ─── Helpers ────────────────────────────────────────────────────── + + +def run(cmd, **kwargs): + """Run a command and return (returncode, stdout, stderr).""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + except FileNotFoundError: + return 127, "", f"command not found: {cmd[0]}" + return result.returncode, result.stdout.strip(), result.stderr.strip() + + +def systemctl(*args): + return run(["systemctl", "--user"] + list(args)) + + +def load_yaml(path): + if path.exists(): + with open(path) as f: + return yaml.safe_load(f) or {} + return {} + + +def dump_yaml(data, path): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + + +def read_frontmatter_field(path, field, width=None): + """Прочитать одно поле из YAML frontmatter .md файла.""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return "" + if not text.startswith("---"): + return "" + parts = text.split("---", 2) + if len(parts) < 2: + return "" + try: + fm = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + return "" + val = " ".join(str(fm.get(field, "")).split()) + return val[:width] if width else val + + +# ─── Catalog ────────────────────────────────────────────────────── + + +def list_catalog(): + """Scan catalog/ for skill sources (git repos with skills/ dirs).""" + if not CATALOG_DIR.exists(): + print(" (empty — no catalog entries)") + return + for entry in sorted(CATALOG_DIR.iterdir()): + if entry.is_dir(): + skills_dir = entry / "skills" + if skills_dir.is_dir(): + skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists()) + print(f" {c(entry.name, 'bold')} ({len(skills)} skills)") + for s in skills: + print(f" ├─ {s}") + else: + print(f" {c(entry.name, 'bold')} (no skills/)") + # Check if it's just a git repo + if (entry / ".git").exists(): + print(f" (git repo, no skills/ dir)") + + +def catalog_skill_dirs(): + """Yield (source_name, skill_name, skill_dir) for every skill in catalog.""" + if not CATALOG_DIR.exists(): + return + for entry in sorted(CATALOG_DIR.iterdir()): + if not entry.is_dir(): + continue + skills_dir = entry / "skills" + if not skills_dir.is_dir(): + continue + for skill_dir in skills_dir.iterdir(): + if (skill_dir / "SKILL.md").exists(): + yield entry.name, skill_dir.name, skill_dir + + +def _submodule_paths(): + """Пути submodule'ов из .gitmodules (относительно корня репы).""" + gitmodules = HARNESS_ROOT / ".gitmodules" + if not gitmodules.exists(): + return set() + rc, out, err = run(["git", "config", "-f", str(gitmodules), "--get-regexp", r"^submodule\..*\.path$"]) + paths = set() + if rc == 0: + for line in out.splitlines(): + parts = line.split(None, 1) + if len(parts) == 2: + paths.add(parts[1].strip()) + return paths + + +def update_catalog(): + """Обновить каталог: submodule'ы — через суперпроект, клоны — git pull.""" + if not CATALOG_DIR.exists(): + warn("catalog/ doesn't exist") + return + + subs = _submodule_paths() + + if subs: + info(f"Обновляю submodule'ы ({len(subs)})...") + rc, out, err = run( + ["git", "submodule", "update", "--remote", "--init", "--recursive"], + cwd=HARNESS_ROOT, + ) + if rc == 0: + rc2, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT) + for line in status.splitlines(): + print(f" {line}") + ok("submodule'ы обновлены (изменения — закоммить отдельно)") + else: + warn(f"submodule update: {err[:200]}") + + for entry in sorted(CATALOG_DIR.iterdir()): + if not entry.is_dir() or not (entry / ".git").exists(): + continue + rel = str(entry.relative_to(HARNESS_ROOT)) + if rel in subs: + continue + info(f"Updating {entry.name} (clone)...") + rc, out, err = run(["git", "pull", "--ff-only"], cwd=entry) + if rc == 0: + ok(f" {entry.name} updated") + else: + warn(f" {entry.name}: {err[:200]}") + + +# ─── Skills ─────────────────────────────────────────────────────── + + +def install_skill(skill_source_name, skill_name, target): + """Install a single skill from catalog to the target agent.""" + # Find the skill + skill_dir = None + for entry in CATALOG_DIR.iterdir(): + if entry.name == skill_source_name or ( + skill_source_name in entry.name + ): + candidate = entry / "skills" / skill_name + if (candidate / "SKILL.md").exists(): + skill_dir = candidate + break + + if not skill_dir: + # Try direct path + candidate = CATALOG_DIR / skill_source_name / "skills" / skill_name + if (candidate / "SKILL.md").exists(): + skill_dir = candidate + + if not skill_dir: + die(f"Skill '{skill_source_name}/{skill_name}' not found in catalog. " + f"Use 'harness list catalog' to see available skills.") + + targets = resolve_targets(target) + + for t in targets: + if t == "hermes": + dest = HERMES_SKILLS / "catalog" / skill_name + _install_skill_dir(skill_dir, dest, t) + elif t == "opencode": + for skill_root in OPENCODE_SKILLS_DIRS: + dest = skill_root / skill_name + _install_skill_dir(skill_dir, dest, t) + + +def install_all_skills(source_name, target): + """Install every skill from a catalog source.""" + source_dir = CATALOG_DIR / source_name + skills_dir = source_dir / "skills" + if not skills_dir.is_dir(): + die(f"Source '{source_name}' not found in catalog, or has no skills/ directory") + + skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists()) + if not skills: + die(f"No skills found in {source_name}/skills/") + + info(f"Installing {len(skills)} skills from '{source_name}' → {target}") + for skill_name in skills: + install_skill(source_name, skill_name, target) + ok(f"All {len(skills)} skills installed") + + +def _remove_path(path): + """Remove a path: symlink → unlink, directory → rmtree, file → unlink.""" + if path.is_symlink() or not path.is_dir(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + + +def _install_skill_dir(src_dir, dest_dir, agent_name): + """Symlink or copy a skill directory to dest.""" + dest_dir.parent.mkdir(parents=True, exist_ok=True) + + if dest_dir.exists(): + warn(f" [{agent_name}] {dest_dir.name} already exists, removing first") + _remove_path(dest_dir) + + try: + os.symlink(str(src_dir.resolve()), str(dest_dir), target_is_directory=True) + ok(f" [{agent_name}] → {dest_dir}") + except OSError: + # Fallback to copy + shutil.copytree(src_dir, dest_dir, symlinks=True) + ok(f" [{agent_name}] → {dest_dir} (copied)") + + +def remove_skill(skill_name, target): + """Remove a skill from target agent.""" + targets = resolve_targets(target) + for t in targets: + if t == "hermes": + paths = list(HERMES_SKILLS.glob(f"*/{skill_name}")) + list(HERMES_SKILLS.glob(f"**/{skill_name}")) + for p in paths: + if p.exists(): + _remove_path(p) + ok(f" [hermes] removed {p}") + elif t == "opencode": + for skill_root in OPENCODE_SKILLS_DIRS: + dest = skill_root / skill_name + if dest.exists(): + _remove_path(dest) + ok(f" [opencode] removed {dest}") + + +def list_skills(target): + """List installed skills.""" + targets = resolve_targets(target) + for t in targets: + if t == "hermes": + print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):") + _print_skill_tree(HERMES_SKILLS) + elif t == "opencode": + for skill_root in OPENCODE_SKILLS_DIRS: + if skill_root.exists(): + print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):") + _print_skill_tree(skill_root) + + +def _print_skill_tree(root, indent=""): + if not root.exists(): + print(f"{indent} (not found)") + return + count = 0 + for entry in sorted(root.iterdir()): + sk = entry / "SKILL.md" + if sk.exists(): + name = entry.name + desc = read_frontmatter_field(sk, "description", 80) + sym = " → " + os.readlink(str(entry)) if entry.is_symlink() else "" + print(f"{indent} ├─ {c(name, 'cyan')}{c(sym, 'dim')}") + if desc: + print(f"{indent} │ {c(desc[:80], 'dim')}") + count += 1 + elif entry.is_dir() and not entry.name.startswith("."): + sub = sum(1 for _ in entry.rglob("SKILL.md")) + if sub > 0: + print(f"{indent} {c(entry.name, 'yellow')}/") + _print_skill_tree(entry, indent + " ") + if count == 0 and indent == "": + print(f" (no skills installed)") + + +def resolve_targets(target): + if target == "all": + return ["hermes", "opencode"] + elif target in ("hermes", "opencode"): + return [target] + else: + die(f"Unknown target: {target}. Use hermes, opencode, or all.") + + +# ─── Agents (OpenCode) ──────────────────────────────────────────── +# +# Агенты поддерживает только OpenCode. Hermes пользовательских агентов +# не имеет — у него только runtime-делегирование (delegate_task). +# +# OpenCode ищет определения в {agent,agents}/**/*.md внутри конфиг-каталогов: +# глобально: ~/.config/opencode/agents/.md +# в проекте: /.opencode/agents/.md +# Имя агента = имя файла без .md. Источник — harness/agents/. + + +def available_agents(): + """Все .md агенты в репозитории (harness/agents/).""" + if not AGENTS_SRC.exists(): + return [] + return sorted(p for p in AGENTS_SRC.glob("*.md") if p.is_file()) + + +def agent_dest_dirs(scope="global", project=None): + """Куда ставятся агенты: список (label, dir).""" + dirs = [] + if scope in ("all", "global"): + dirs.append(("opencode/global", OPENCODE_AGENTS_GLOBAL)) + if scope in ("all", "project"): + root = Path(project).expanduser().resolve() if project else Path.cwd() + dirs.append(("opencode/project", root / ".opencode" / "agents")) + return dirs + + +def list_agents(scope="all", project=None): + """Показать доступных в репо и установленных агентов.""" + avail = available_agents() + print(f"{c('Available agents', 'bold')} ({AGENTS_SRC}):") + if not avail: + print(" (none)") + for p in avail: + mode = read_frontmatter_field(p, "mode") or "?" + desc = read_frontmatter_field(p, "description", 72) + print(f" ├─ {c(p.stem, 'cyan')} {c('[' + mode + ']', 'dim')}") + if desc: + print(f" │ {c(desc, 'dim')}") + + for label, d in agent_dest_dirs(scope, project): + print(f"\n{c('Installed — ' + label, 'bold')} ({d}):") + if not d.exists(): + print(" (dir not found)") + continue + items = sorted(d.glob("*.md")) + if not items: + print(" (none)") + for p in items: + sym = " → " + os.readlink(str(p)) if p.is_symlink() else "" + print(f" ├─ {c(p.stem, 'cyan')}{c(sym, 'dim')}") + + +def install_agent(name=None, install_all=False, scope="global", project=None): + """Установить агента(ов) в каталоги OpenCode симлинком.""" + avail = available_agents() + if not avail: + die(f"Нет агентов в {AGENTS_SRC}") + + names = ", ".join(p.stem for p in avail) + if install_all: + sources = avail + else: + if not name: + die(f"Укажи имя агента или --all. Доступны: {names}") + src = AGENTS_SRC / f"{name}.md" + if not src.exists(): + die(f"Агент '{name}' не найден. Доступны: {names}") + sources = [src] + + dests = agent_dest_dirs(scope, project) + info(f"Устанавливаю {len(sources)} агент(ов) → {', '.join(l for l, _ in dests)}") + for src in sources: + for label, d in dests: + _link_agent(src, d / src.name, label) + ok(f"Готово: {len(sources)} агент(ов)") + + +def _link_agent(src, dest, label): + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() or dest.is_symlink(): + warn(f" [{label}] {dest.name} уже есть — заменяю") + _remove_path(dest) + try: + os.symlink(str(src.resolve()), str(dest)) + ok(f" [{label}] → {dest}") + except OSError: + shutil.copy2(src, dest) + ok(f" [{label}] → {dest} (скопирован)") + + +def remove_agent(name, scope="all", project=None): + """Удалить агента из каталогов OpenCode.""" + removed = 0 + for label, d in agent_dest_dirs(scope, project): + dest = d / f"{name}.md" + if dest.exists() or dest.is_symlink(): + _remove_path(dest) + ok(f" [{label}] удалён {dest}") + removed += 1 + if not removed: + warn(f"Агент '{name}' не найден в области '{scope}'") + + +# ─── Hermes MCP ─────────────────────────────────────────────────── + + +def list_mcp(): + config = load_yaml(HERMES_CONFIG) + servers = config.get("mcp_servers", {}) + if not servers: + print(" No MCP servers configured") + return + print(f"{c('Hermes MCP Servers', 'bold')} ({HERMES_CONFIG}):") + for name, cfg in sorted(servers.items()): + enabled = cfg.get("enabled", True) + status = c("✓", "green") if enabled else c("✗", "red") + source = cfg.get("url") or " ".join(cfg.get("args", [])) + print(f" {status} {c(name, 'cyan')} → {c(source, 'dim')}") + + +def mcp_add(name, source, env_vars=None, args=None): + config = load_yaml(HERMES_CONFIG) + if "mcp_servers" not in config: + config["mcp_servers"] = {} + + if name in config["mcp_servers"]: + warn(f"MCP server '{name}' already exists, overwriting") + + if source.startswith(("http://", "https://")): + entry = {"url": source, "enabled": True} + else: + entry = {"command": source, "args": args or [], "enabled": True} + if env_vars: + entry["env"] = env_vars + + config["mcp_servers"][name] = entry + dump_yaml(config, HERMES_CONFIG) + ok(f"MCP server '{name}' added to Hermes config") + + # Restart Hermes gateway to pick up changes + info("Restarting Hermes gateway to apply changes...") + rc, out, err = systemctl("restart", "hermes-gateway.service") + if rc == 0: + ok("Hermes gateway restarted") + else: + warn(f"Could not restart Hermes gateway: {err[:200]}") + + +def mcp_rm(name): + config = load_yaml(HERMES_CONFIG) + servers = config.get("mcp_servers", {}) + if name not in servers: + die(f"MCP server '{name}' not found") + del servers[name] + config["mcp_servers"] = servers + dump_yaml(config, HERMES_CONFIG) + ok(f"MCP server '{name}' removed from Hermes config") + + rc, out, err = systemctl("restart", "hermes-gateway.service") + if rc == 0: + ok("Hermes gateway restarted") + else: + warn(f"Could not restart Hermes gateway: {err[:200]}") + + +# ─── Hermes Configure ────────────────────────────────────────────── + + +def configure_hermes(kv_pairs=None): + """Set key=value pairs in Hermes config.""" + config = load_yaml(HERMES_CONFIG) + if not kv_pairs: + # Show current config + print(c("Hermes config", "bold"), f"({HERMES_CONFIG}):") + for key, val in config.items(): + if key in ("mcp_servers",): + continue # handled separately + if isinstance(val, dict): + print(f" {c(key, 'cyan')}:") + for k, v in val.items(): + print(f" {k}: {v}") + else: + print(f" {c(key, 'cyan')}: {val}") + return + + for pair in kv_pairs: + if "=" not in pair: + warn(f"Skipping '{pair}' — expected key=value") + continue + key, value = pair.split("=", 1) + # Support dotted keys like "model.default=deepseek" + parts = key.split(".", 1) + if len(parts) == 2: + section, subkey = parts + if section not in config: + config[section] = {} + config[section][subkey] = _parse_value(value) + else: + config[key] = _parse_value(value) + ok(f"Set {key} = {value}") + + dump_yaml(config, HERMES_CONFIG) + info("Restarting Hermes to apply config changes...") + systemctl("restart", "hermes-gateway.service") + systemctl("restart", "hermes-webui.service") + + +def _parse_value(value): + """Parse YAML-like values.""" + if value.lower() in ("true", "yes", "on"): + return True + if value.lower() in ("false", "no", "off"): + return False + if value.isdigit(): + return int(value) + try: + return float(value) + except ValueError: + pass + return value + + +# ─── Install Agents Binaries ────────────────────────────────────── + + +def install_opencode(): + info("Installing/updating OpenCode...") + install_script = "https://opencode.ai/install" + rc, out, err = run( + ["bash", "-c", f"curl -fsSL {install_script} | bash"], + timeout=120, + ) + if rc == 0: + ok("OpenCode installed/updated") + else: + die(f"OpenCode install failed: {err}") + + +def install_hermes(): + info("Updating Hermes...") + rc, out, err = run(["hermes", "update"], timeout=120) + if rc == 0: + ok("Hermes updated") + # Restart services + restart_hermes() + else: + warn(f"hermes update failed: {err}") + info("Hermes is installed from git. Try: cd ~/.hermes/hermes-agent && git pull && pip install -e .") + + +# ─── Restart ────────────────────────────────────────────────────── + + +def restart_hermes(): + for svc in HERMES_SERVICES: + rc, out, err = systemctl("restart", svc) + if rc == 0: + ok(f"{svc} restarted") + else: + warn(f"Failed to restart {svc}: {err[:200]}") + + +def restart_opencode(): + for svc in OPENCODE_SERVICES: + rc, out, err = systemctl("restart", svc) + if rc == 0: + ok(f"{svc} restarted") + else: + warn(f"Failed to restart {svc}: {err[:200]}") + + +# ─── Status ──────────────────────────────────────────────────────── + + +def _service_line(svc): + rc, out, err = systemctl("is-active", svc) + status = c("● running", "green") if rc == 0 else c("○ stopped", "red") + print(f" {status} {svc}") + + +def show_status(): + print(c("═" * 50, "bold")) + print(c("Harness — Agent Environment Status", "bold")) + print(c("═" * 50, "bold")) + + # Hermes + print(f"\n{c('Hermes Agent', 'bold')}") + hermes_path = shutil.which("hermes") + if hermes_path: + rc, out, err = run([hermes_path, "--version"]) + if rc == 0: + print(f" Version: {c(out, 'cyan')}") + else: + print(f" {c('Not found', 'red')}") + + for svc in HERMES_SERVICES: + _service_line(svc) + + config = load_yaml(HERMES_CONFIG) + servers = config.get("mcp_servers", {}) + enabled = sum(1 for s in servers.values() if s.get("enabled", True)) + print(f" MCP servers: {c(str(len(servers)), 'cyan')} ({enabled} enabled)") + + skill_count = sum(1 for _ in HERMES_SKILLS.rglob("SKILL.md")) if HERMES_SKILLS.exists() else 0 + print(f" Skills: {c(str(skill_count), 'cyan')}") + + # OpenCode + print(f"\n{c('OpenCode', 'bold')}") + opencode_path = shutil.which("opencode") + if opencode_path: + rc, out, err = run([opencode_path, "--version"]) + if rc == 0: + print(f" Version: {c(out, 'cyan')}") + else: + print(f" Binary: {c(opencode_path, 'cyan')}") + else: + for candidate in ["/usr/local/bin/opencode", os.path.expanduser("~/.local/bin/opencode")]: + if os.path.exists(candidate): + opencode_path = candidate + break + if opencode_path: + print(f" Binary: {c(opencode_path, 'dim')} (on disk, not in PATH)") + else: + print(f" {c('Not installed', 'red')}") + + for svc in OPENCODE_SERVICES: + _service_line(svc) + + oc_skills = 0 + for d in OPENCODE_SKILLS_DIRS: + if d.exists(): + oc_skills += sum(1 for _ in d.rglob("SKILL.md")) + print(f" Skills: {c(str(oc_skills), 'cyan')}") + + oc_agents = len(list(OPENCODE_AGENTS_GLOBAL.glob("*.md"))) if OPENCODE_AGENTS_GLOBAL.exists() else 0 + repo_agents = len(available_agents()) + print(f" Agents: {c(str(oc_agents), 'cyan')} (в репо: {c(str(repo_agents), 'cyan')})") + + # Catalog + print(f"\n{c('Catalog', 'bold')}") + if CATALOG_DIR.exists(): + entries = [d for d in CATALOG_DIR.iterdir() if d.is_dir()] + print(f" Sources: {c(str(len(entries)), 'cyan')}") + for e in entries: + skills_dir = e / "skills" + n = len(list(skills_dir.glob("*/SKILL.md"))) if skills_dir.exists() else 0 + print(f" ├─ {c(e.name, 'cyan')} ({n} skills)") + else: + print(f" {c('Not found', 'red')}") + + print() + + +# ─── Main CLI ───────────────────────────────────────────────────── + + +def main(): + parser = argparse.ArgumentParser( + description="Harness — Manager for Hermes & OpenCode skills, agents, MCP, and configs", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + harness status + harness list catalog + harness list agents + harness install skill jakubkrehel-skills better-ui --target all + harness install skill jakubkrehel-skills --all --target opencode + harness install agent leader --scope global + harness install agent --all --scope project --project ~/work/myrepo + harness install opencode + harness configure hermes --set model.default=gpt-4o + harness configure hermes mcp add my-server http://localhost:8000/mcp + harness configure hermes mcp add github npx --env GITHUB_TOKEN=xxx --arg -y --arg @modelcontextprotocol/server-github + harness remove skill better-ui --target hermes + harness remove agent leader + harness update catalog + """, + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + + sub = parser.add_subparsers(dest="command", required=True) + + # install + install_parser = sub.add_parser("install", help="Install things") + install_sub = install_parser.add_subparsers(dest="install_type", required=True) + + # install skill + skill_parser = install_sub.add_parser("skill") + skill_parser.add_argument("source", help="Catalog source name (e.g. jakubkrehel-skills)") + skill_parser.add_argument("skill", nargs="?", default=None, help="Skill name (or omit with --all)") + skill_parser.add_argument("--target", default="all", choices=["hermes", "opencode", "all"]) + skill_parser.add_argument("--all", action="store_true", help="Install all skills from the source") + + # install agent (OpenCode only) + agent_parser = install_sub.add_parser("agent", help="Install OpenCode agent definition(s)") + agent_parser.add_argument("name", nargs="?", default=None, help="Agent name (file name without .md)") + agent_parser.add_argument("--all", action="store_true", help="Install all agents") + agent_parser.add_argument("--scope", default="global", choices=["global", "project", "all"], + help="global → ~/.config/opencode/agents; project → /.opencode/agents") + agent_parser.add_argument("--project", default=None, help="Project root (default: cwd)") + + # install opencode / hermes + install_sub.add_parser("opencode", help="Install/update OpenCode binary") + install_sub.add_parser("hermes", help="Update Hermes") + + # list + list_parser = sub.add_parser("list", help="List installed items") + list_sub = list_parser.add_subparsers(dest="list_type", required=True) + list_sub.add_parser("skills", help="List installed skills").add_argument( + "--target", default="all", choices=["hermes", "opencode", "all"] + ) + list_agents_p = list_sub.add_parser("agents", help="List OpenCode agents") + list_agents_p.add_argument("--scope", default="all", choices=["global", "project", "all"]) + list_agents_p.add_argument("--project", default=None, help="Project root (default: cwd)") + list_sub.add_parser("mcp", help="List MCP servers") + list_sub.add_parser("catalog", help="List catalog entries") + + # remove + remove_parser = sub.add_parser("remove", help="Remove items") + remove_sub = remove_parser.add_subparsers(dest="remove_type", required=True) + rm_skill = remove_sub.add_parser("skill") + rm_skill.add_argument("name", help="Skill name to remove") + rm_skill.add_argument("--target", default="all", choices=["hermes", "opencode", "all"]) + rm_agent = remove_sub.add_parser("agent") + rm_agent.add_argument("name", help="Agent name to remove") + rm_agent.add_argument("--scope", default="all", choices=["global", "project", "all"]) + rm_agent.add_argument("--project", default=None, help="Project root (default: cwd)") + rm_mcp = remove_sub.add_parser("mcp") + rm_mcp.add_argument("name", help="MCP server name to remove") + + # configure + configure_parser = sub.add_parser("configure", help="Configure agents") + configure_sub = configure_parser.add_subparsers(dest="configure_type", required=True) + hermes_conf = configure_sub.add_parser("hermes", help="Configure Hermes") + hermes_conf_sub = hermes_conf.add_subparsers(dest="hermes_action") + + # configure hermes (plain — show or set) + hermes_conf.add_argument("--set", "-s", action="append", default=[], + help="key=value pairs (repeatable). Omit to show config.") + + # configure hermes mcp + mcp_parser = hermes_conf_sub.add_parser("mcp", help="Manage Hermes MCP servers") + mcp_sub = mcp_parser.add_subparsers(dest="mcp_action", required=True) + mcp_add = mcp_sub.add_parser("add") + mcp_add.add_argument("name") + mcp_add.add_argument("source", help="URL or command") + mcp_add.add_argument("--env", action="append", default=[], help="ENV=VAR for command servers") + mcp_add.add_argument("--args", nargs="*", default=[], help="Extra args (e.g. -y @package)") + mcp_add.add_argument("--arg", "-a", dest="args_list", action="append", default=[], + help="Extra arg (repeatable, for npx packages)") + + mcp_rm = mcp_sub.add_parser("rm") + mcp_rm.add_argument("name", help="MCP server name to remove") + + # restart + restart_parser = sub.add_parser("restart", help="Restart agents") + restart_parser.add_argument("target", choices=["hermes", "opencode"], help="Agent to restart") + + # status + sub.add_parser("status", help="Show status of everything") + + # update + sub.add_parser("update", help="Update catalog").add_argument("type", nargs="?", default="catalog") + + args = parser.parse_args() + os.makedirs(CATALOG_DIR, exist_ok=True) + + # ── Commands ────────────────────────────────────────────── + + if args.command == "install": + if args.install_type == "skill": + source = args.source + skill = args.skill + + if "/" in source and not skill: + source, skill = source.split("/", 1) + + if args.all: + install_all_skills(source, args.target) + elif skill is None: + die("Specify skill name: harness install skill or use --all") + else: + install_skill(source, skill, args.target) + + elif args.install_type == "agent": + install_agent(args.name, args.all, args.scope, args.project) + + elif args.install_type == "opencode": + install_opencode() + + elif args.install_type == "hermes": + install_hermes() + + elif args.command == "list": + if args.list_type == "skills": + list_skills(args.target) + elif args.list_type == "agents": + list_agents(args.scope, args.project) + elif args.list_type == "mcp": + list_mcp() + elif args.list_type == "catalog": + list_catalog() + + elif args.command == "remove": + if args.remove_type == "skill": + remove_skill(args.name, args.target) + elif args.remove_type == "agent": + remove_agent(args.name, args.scope, args.project) + elif args.remove_type == "mcp": + mcp_rm(args.name) + + elif args.command == "configure": + if args.configure_type == "hermes": + if hasattr(args, "hermes_action") and args.hermes_action == "mcp": + if args.mcp_action == "add": + # Combine --args and --arg + all_args = args.args + args.args_list + env_dict = {} + for e in args.env: + if "=" in e: + k, v = e.split("=", 1) + env_dict[k] = v + mcp_add(args.name, args.source, env_vars=env_dict, args=all_args) + elif args.mcp_action == "rm": + mcp_rm(args.name) + else: + configure_hermes(args.set if hasattr(args, "set") else None) + else: + die(f"Configure target not supported: {args.configure_type}") + + elif args.command == "restart": + if args.target == "hermes": + restart_hermes() + else: + restart_opencode() + + elif args.command == "status": + show_status() + + elif args.command == "update": + update_catalog() + + +if __name__ == "__main__": + main()