feat: harness — менеджер скиллов, агентов и MCP для Hermes и OpenCode
- scripts/harness: CLI (skills, agents, MCP, конфиг Hermes, рестарт, статус) - agents/: leader, fixer, researcher, tester + devops, admin - catalog/jakubkrehel-skills: submodule → github.com/jakubkrehel/skills - README: usage, форматы, таблица совместимости
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# project-local installs (harness install agent --scope project)
|
||||||
|
.opencode/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "catalog/jakubkrehel-skills"]
|
||||||
|
path = catalog/jakubkrehel-skills
|
||||||
|
url = https://github.com/jakubkrehel/skills.git
|
||||||
@@ -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/<name>/` |
|
||||||
|
| OpenCode | `~/.claude/skills/<name>/` |
|
||||||
|
| OpenCode | `~/.config/opencode/skills/<name>/` |
|
||||||
|
|
||||||
|
### Управление скиллами
|
||||||
|
|
||||||
|
```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/<name>.md` |
|
||||||
|
| `project` | `<project>/.opencode/agents/<name>.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/<name>/SKILL.md` видны через `harness list catalog`.
|
||||||
|
- **Агенты** — `<name>.md` с frontmatter (`description`, `mode`, ...); формат OpenCode.
|
||||||
|
Лежат в `agents/<name>.md`.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
| Фича | Hermes | OpenCode |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Skills (SKILL.md) | ✅ `~/.hermes/skills/` | ✅ `~/.claude/skills/`, `~/.config/opencode/skills/` |
|
||||||
|
| Agents | ❌ (только `delegate_task` в рантайме) | ✅ `~/.config/opencode/agents/`, `<project>/.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 |
|
||||||
|
|||||||
+118
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
- **Коммит:** {хеш}
|
||||||
|
- **Время:** {мин}
|
||||||
|
- **Проблемы:** {что пошло не так}
|
||||||
|
```
|
||||||
@@ -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, импорты.
|
||||||
|
- Ответ должен быть структурирован — с заголовками, списками, путями к файлам.
|
||||||
|
- Если не можешь ответить полно — скажи что ещё нужно проверить.
|
||||||
@@ -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.
|
||||||
|
- Не говори «наверное ок». Докажи или опровергни.
|
||||||
Submodule
+1
Submodule catalog/jakubkrehel-skills added at 267330e1ad
Executable
+911
@@ -0,0 +1,911 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Harness — универсальный менеджер скиллов, агентов, MCP-серверов и конфигов
|
||||||
|
для Hermes и OpenCode.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
harness install skill <source> [--all] [--target opencode|hermes|all]
|
||||||
|
harness install agent <name> [--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 <name> [--target opencode|hermes|all]
|
||||||
|
harness remove agent <name> [--scope global|project|all] [--project DIR]
|
||||||
|
harness remove mcp <name>
|
||||||
|
harness configure hermes [--set key=value ...]
|
||||||
|
harness configure hermes mcp add <name> <url|command> [--env KEY=VAL] [--args ...]
|
||||||
|
harness configure hermes mcp rm <name>
|
||||||
|
harness restart <hermes|opencode>
|
||||||
|
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/<name>.md
|
||||||
|
# в проекте: <project>/.opencode/agents/<name>.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 → <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 <source> <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()
|
||||||
Reference in New Issue
Block a user