refactor: harness CLI — объект первым, действие вторым

- грамматика: harness <объект> <действие> (skills / agents / mcp / catalog / hermes / opencode)
- skills list теперь показывает каталог: что доступно к установке, с отметками ✓/·
- skills installed — прежний вид «что реально стоит» (включая не из каталога)
- фикс: установленные скиллы не находились — Path.rglob не разворачивает симлинки, перешёл на os.walk(followlinks=True)
- в листинге каталога виден sha источника и признак «⚠ сдвинут»
- README переписан под новую грамматику
This commit is contained in:
eka
2026-09-23 12:38:55 +05:00
parent 7e2ea5a275
commit da70365701
2 changed files with 464 additions and 310 deletions
+115 -90
View File
@@ -15,35 +15,90 @@ dev/harness/
└── README.md └── README.md
``` ```
## Грамматика
**Объект — первым, действие — вторым:** `harness <объект> <действие> [аргументы]`
| Объект | Что это | Действия |
|--------|---------|----------|
| `status` | среда целиком | — |
| `skills` | скиллы из каталога | `list`, `installed`, `install`, `remove` |
| `agents` | агенты (только OpenCode) | `list`, `install`, `remove` |
| `mcp` | MCP-серверы Hermes | `list`, `add`, `rm` |
| `catalog` | внешние репозитории каталога | `list`, `update` |
| `hermes` | сам Hermes | `config`, `restart`, `update` |
| `opencode` | сам OpenCode | `install`, `restart` |
```bash
./scripts/harness status
./scripts/harness skills list
./scripts/harness skills install jakubkrehel-skills better-ui --target all
./scripts/harness agents install leader
./scripts/harness mcp list
./scripts/harness catalog update
./scripts/harness hermes restart
```
## Быстрый старт ## Быстрый старт
```bash ```bash
./scripts/harness status # Статус всей среды ./scripts/harness status # Обзор среды
./scripts/harness list catalog # Что есть в каталоге скиллов ./scripts/harness skills list # Что доступно в каталоге (+ отметки установки)
./scripts/harness list skills # Установленные скиллы ./scripts/harness skills installed # Что реально стоит у агентов
./scripts/harness list agents # Агенты: доступные в репо + установленные ./scripts/harness agents list # Агенты: доступные в репо + установленные
./scripts/harness list mcp # MCP-серверы Hermes ./scripts/harness mcp list # MCP-серверы Hermes
./scripts/harness catalog list # Источники каталога
``` ```
## Команды ## `skills` — скиллы из каталога
### Установка скиллов ### `skills list` — что доступно к установке
Показывает содержимое каталога и где скилл уже стоит. `✓` — установлено, `·` — нет.
```bash ```bash
# Установить скилл из каталога ./scripts/harness skills list
./scripts/harness install skill jakubkrehel-skills better-ui --target opencode ./scripts/harness skills list --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
``` ```
Скиллы ставятся симлинками в соответствующие директории: ```
Доступно из каталога (/mnt/data/Agent/dev/harness/catalog)
✓ — установлено, · — нет
jakubkrehel-skills 11 скиллов 267330e
├─ better-ui hermes · opencode ✓
├─ better-colors hermes · opencode ·
...
```
Для git-источников показывается короткий sha; `⚠ сдвинут` — submodule ушёл вперёд
и это ещё не закоммичено.
### `skills installed` — что реально стоит
Полное дерево каталогов агента, включая скиллы не из каталога.
```bash
./scripts/harness skills installed
./scripts/harness skills installed --target hermes
```
### `skills install` / `skills remove`
```bash
# Один скилл
./scripts/harness skills install jakubkrehel-skills better-ui --target opencode
./scripts/harness skills install jakubkrehel-skills/better-ui --target all
# Все скиллы источника
./scripts/harness skills install jakubkrehel-skills --all --target opencode
# Удалить
./scripts/harness skills remove better-ui
./scripts/harness skills remove better-ui --target hermes
```
Скиллы ставятся **симлинками**:
| Агент | Куда ставится | | Агент | Куда ставится |
|-------|--------------| |-------|--------------|
@@ -51,33 +106,21 @@ dev/harness/
| OpenCode | `~/.claude/skills/<name>/` | | OpenCode | `~/.claude/skills/<name>/` |
| OpenCode | `~/.config/opencode/skills/<name>/` | | OpenCode | `~/.config/opencode/skills/<name>/` |
### Управление скиллами ## `agents` — агенты (только OpenCode)
```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`. Источник — `harness/agents/*.md`. Имя агента = имя файла без `.md`.
```bash ```bash
# Список: что есть в репо + что уже установлено ./scripts/harness agents list
./scripts/harness list agents ./scripts/harness agents list --scope global
./scripts/harness list agents --scope global # только глобальные ./scripts/harness agents list --scope project --project ~/work/myrepo
./scripts/harness list agents --scope project --project ~/work/myrepo
# Установить одного / всех ./scripts/harness agents install leader # в ~/.config/opencode/agents/
./scripts/harness install agent leader # в ~/.config/opencode/agents/ ./scripts/harness agents install --all --scope global
./scripts/harness install agent --all --scope global # всех, глобально ./scripts/harness agents install devops --scope project --project ~/work/myrepo
./scripts/harness install agent devops --scope project --project ~/work/myrepo
# Удалить ./scripts/harness agents remove leader
./scripts/harness remove agent leader # отовсюду ./scripts/harness agents remove leader --scope global
./scripts/harness remove agent leader --scope global
``` ```
| `--scope` | Куда ставится | | `--scope` | Куда ставится |
@@ -89,60 +132,47 @@ dev/harness/
`mode: all` (кроме `leader` — `primary`), чтобы лидер мог вызывать их как сабагентов. `mode: all` (кроме `leader` — `primary`), чтобы лидер мог вызывать их как сабагентов.
> **Hermes агентов не поддерживает** — только runtime-делегирование через > **Hermes агентов не поддерживает** — только runtime-делегирование через
> `delegate_task`. Пользовательских определений агентов у него нет, поэтому > `delegate_task`. Пользовательских определений агентов у него нет.
> `install agent` работает только для OpenCode.
### MCP-серверы (Hermes) ## `mcp` — MCP-серверы Hermes
```bash ```bash
# Добавить MCP по URL (HTTP SSE) ./scripts/harness mcp list
./scripts/harness configure hermes mcp add my-server http://localhost:8000/mcp
# Добавить MCP через команду (STDIO) # По URL (HTTP/SSE)
./scripts/harness configure hermes mcp add github npx \ ./scripts/harness mcp add my-server http://localhost:8000/mcp
# Командой (stdio)
./scripts/harness mcp add github npx \
--env GITHUB_TOKEN=ghp_xxx \ --env GITHUB_TOKEN=ghp_xxx \
--arg -y --arg @modelcontextprotocol/server-github --arg -y --arg @modelcontextprotocol/server-github
# Список MCP ./scripts/harness mcp rm my-server
./scripts/harness list mcp
# Удалить MCP
./scripts/harness configure hermes mcp rm my-server
``` ```
### Конфигурация Hermes `add` и `rm` сами перезапускают `hermes-gateway`, чтобы изменения подхватились.
## `catalog` — внешние репозитории
```bash ```bash
# Посмотреть конфиг ./scripts/harness catalog list # источники, содержимое, sha, признак «сдвинут»
./scripts/harness configure hermes ./scripts/harness catalog update # git submodule update --remote / git pull для клонов
# Установить параметры
./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. ## `hermes` / `opencode` — сам агент
### Установка/обновление бинарников
```bash ```bash
./scripts/harness install opencode # curl-установщик opencode.ai ./scripts/harness hermes config # показать конфиг
./scripts/harness install hermes # hermes update + рестарт сервисов ./scripts/harness hermes config --set model.default=gpt-4o
./scripts/harness hermes config --set agent.max_turns=200
./scripts/harness hermes restart # systemctl --user restart hermes-gateway + webui
./scripts/harness hermes update # hermes update + рестарт сервисов
./scripts/harness opencode install # curl-установщик opencode.ai
./scripts/harness opencode restart # systemctl --user restart opencode + openchamber
``` ```
### Перезапуск `hermes config --set` меняет `~/.hermes/config.yaml` и перезапускает сервисы.
```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/
```
## Как добавить скилл в каталог ## Как добавить скилл в каталог
@@ -154,19 +184,16 @@ git submodule add https://github.com/user/skills-repo.git catalog/user-skills
git commit -m "chore: add user-skills to catalog" git commit -m "chore: add user-skills to catalog"
``` ```
После этого `harness list catalog` покажет все найденные скиллы, а После этого `harness skills list` покажет найденные скиллы, а
`harness update catalog` подтянет свежие коммиты во всех submodule'ах. `harness catalog update` подтянет свежие коммиты во всех submodule'ах.
Обычный клон (`git clone` внутрь `catalog/`) тоже работает — `update catalog`
обновит его через `git pull`, но такой каталог не попадёт в коммит репы
(вложенная репа не станет submodule автоматически).
### Раскладка репозитория и ручная установка ### Раскладка репозитория и ручная установка
CLI `harness` понимает одну раскладку — `<репа>/skills/<name>/SKILL.md`. CLI понимает одну раскладку — `<репа>/skills/<name>/SKILL.md`. Репозитории с другой
Репозитории с другой структурой он показывает как `(no skills/)`, и их скиллы структурой (`SKILL.md` в корне, `.agents/skills/`, вложенные категории) он показывает
ставятся вручную. Ограничение только у CLI: сам OpenCode сканирует рекурсивно без списка скиллов, и такие скиллы ставятся вручную. Ограничение только у CLI: сам
(`skills/**/SKILL.md`, а через `skills.paths` — `**/SKILL.md` на любой глубине). OpenCode сканирует рекурсивно (`skills/**/SKILL.md`, а через `skills.paths` —
`**/SKILL.md` на любой глубине).
Пример — `developer-documentation-skill` держит `SKILL.md` в корне репы: Пример — `developer-documentation-skill` держит `SKILL.md` в корне репы:
@@ -186,7 +213,7 @@ ln -s /mnt/data/Agent/dev/harness/catalog/developer-documentation-skill \
## Как добавить агента ## Как добавить агента
Положить `.md` файл в `agents/` репозитория — и он сразу виден через `harness list agents`. Положить `.md` файл в `agents/` репозитория — и он сразу виден через `harness agents list`.
Формат — как у OpenCode: Формат — как у OpenCode:
@@ -215,9 +242,7 @@ permission:
## Поддерживаемые форматы ## Поддерживаемые форматы
- **Скиллы** — `SKILL.md` с YAML frontmatter (`name`, `description`); родной формат OpenCode и Hermes. - **Скиллы** — `SKILL.md` с YAML frontmatter (`name`, `description`); родной формат OpenCode и Hermes.
Скиллы из `catalog/*/skills/<name>/SKILL.md` видны через `harness list catalog`.
- **Агенты** — `<name>.md` с frontmatter (`description`, `mode`, ...); формат OpenCode. - **Агенты** — `<name>.md` с frontmatter (`description`, `mode`, ...); формат OpenCode.
Лежат в `agents/<name>.md`.
## Совместимость ## Совместимость
+345 -216
View File
@@ -1,26 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Harness — универсальный менеджер скиллов, агентов, MCP-серверов и конфигов Harness — менеджер скиллов, агентов, MCP-серверов и конфигов
для Hermes и OpenCode. для 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 status
harness update catalog harness skills list [--target hermes|opencode|all]
harness skills installed [--target ...]
harness skills install <source> [<skill>] [--all] [--target ...]
harness skills remove <name> [--target ...]
harness agents list [--scope global|project|all] [--project DIR]
harness agents install <name>|--all [--scope ...] [--project DIR]
harness agents remove <name> [--scope ...] [--project DIR]
harness mcp list
harness mcp add <name> <url|command> [--env KEY=VAL] [--arg ...]
harness mcp rm <name>
harness catalog list
harness catalog update
harness hermes config [--set key=value ...]
harness hermes restart
harness hermes update
harness opencode install
harness opencode restart
""" """
import argparse import argparse
@@ -130,39 +132,27 @@ def read_frontmatter_field(path, field, width=None):
# ─── Catalog ────────────────────────────────────────────────────── # ─── Catalog ──────────────────────────────────────────────────────
def list_catalog(): def catalog_entries():
"""Scan catalog/ for skill sources (git repos with skills/ dirs).""" """Источники каталога: [(name, dir, [skill_names])], отсортировано."""
entries = []
if not CATALOG_DIR.exists(): if not CATALOG_DIR.exists():
print(" (empty — no catalog entries)") return 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()): for entry in sorted(CATALOG_DIR.iterdir()):
if not entry.is_dir(): if not entry.is_dir():
continue continue
skills_dir = entry / "skills" skills_dir = entry / "skills"
if not skills_dir.is_dir(): names = []
continue if skills_dir.is_dir():
for skill_dir in skills_dir.iterdir(): names = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists())
if (skill_dir / "SKILL.md").exists(): entries.append((entry.name, entry, names))
yield entry.name, skill_dir.name, skill_dir return entries
def catalog_skill_dirs():
"""Yield (source_name, skill_name, skill_dir) for every skill in catalog."""
for name, entry, skills in catalog_entries():
for skill_name in skills:
yield name, skill_name, entry / "skills" / skill_name
def _submodule_paths(): def _submodule_paths():
@@ -180,6 +170,54 @@ def _submodule_paths():
return paths return paths
def source_git_state(entry):
"""(short_sha, drift) для git-источника: sha и признак «сдвинут и не закоммичен»."""
if not (entry / ".git").exists():
return None, False
rc, sha, _ = run(["git", "-C", str(entry), "rev-parse", "--short", "HEAD"])
if rc != 0 or not sha:
return None, False
rel = str(entry.relative_to(HARNESS_ROOT))
rc2, line, _ = run(["git", "ls-tree", "HEAD", rel], cwd=HARNESS_ROOT)
drift = False
if rc2 == 0 and line:
parts = line.split()
if len(parts) >= 3 and parts[0] == "160000":
drift = not parts[2].startswith(sha)
return sha, drift
def list_catalog():
"""Источники каталога и их содержимое."""
entries = catalog_entries()
print(c("Каталог источников", "bold"), c(f"({CATALOG_DIR})", "dim"))
if not entries:
print(" (пусто)")
return
total = 0
for name, entry, skills in entries:
sha, drift = source_git_state(entry)
tail = []
if sha:
tail.append(c(sha, "dim"))
if drift:
tail.append(c("⚠ сдвинут, не закоммичен", "yellow"))
suffix = (" " + " ".join(tail)) if tail else ""
if skills:
print(f" {c(name, 'bold')} {c(str(len(skills)) + ' скиллов', 'dim')}{suffix}")
for s in skills:
print(f" ├─ {s}")
total += len(skills)
elif (entry / "SKILL.md").exists():
nm = read_frontmatter_field(entry / "SKILL.md", "name") or name
print(f" {c(name, 'bold')} {c('SKILL.md в корне репы', 'yellow')}{suffix}")
print(f" ├─ {nm} {c('(ставится вручную)', 'dim')}")
total += 1
else:
print(f" {c(name, 'bold')} {c('нет skills/', 'dim')}{suffix}")
print(f"\n Всего скиллов: {c(str(total), 'cyan')}")
def update_catalog(): def update_catalog():
"""Обновить каталог: submodule'ы — через суперпроект, клоны — git pull.""" """Обновить каталог: submodule'ы — через суперпроект, клоны — git pull."""
if not CATALOG_DIR.exists(): if not CATALOG_DIR.exists():
@@ -239,8 +277,8 @@ def install_skill(skill_source_name, skill_name, target):
skill_dir = candidate skill_dir = candidate
if not skill_dir: if not skill_dir:
die(f"Skill '{skill_source_name}/{skill_name}' not found in catalog. " die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. "
f"Use 'harness list catalog' to see available skills.") f"Что есть — 'harness skills list'.")
targets = resolve_targets(target) targets = resolve_targets(target)
@@ -259,16 +297,16 @@ def install_all_skills(source_name, target):
source_dir = CATALOG_DIR / source_name source_dir = CATALOG_DIR / source_name
skills_dir = source_dir / "skills" skills_dir = source_dir / "skills"
if not skills_dir.is_dir(): if not skills_dir.is_dir():
die(f"Source '{source_name}' not found in catalog, or has no skills/ directory") die(f"Источник '{source_name}' не найден в каталоге, или у него нет skills/")
skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists()) skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists())
if not skills: if not skills:
die(f"No skills found in {source_name}/skills/") die(f"В {source_name}/skills/ нет скиллов")
info(f"Installing {len(skills)} skills from '{source_name}' → {target}") info(f"Устанавливаю {len(skills)} скиллов из '{source_name}' → {target}")
for skill_name in skills: for skill_name in skills:
install_skill(source_name, skill_name, target) install_skill(source_name, skill_name, target)
ok(f"All {len(skills)} skills installed") ok(f"Готово: {len(skills)} скиллов")
def _remove_path(path): def _remove_path(path):
@@ -284,7 +322,7 @@ def _install_skill_dir(src_dir, dest_dir, agent_name):
dest_dir.parent.mkdir(parents=True, exist_ok=True) dest_dir.parent.mkdir(parents=True, exist_ok=True)
if dest_dir.exists(): if dest_dir.exists():
warn(f" [{agent_name}] {dest_dir.name} already exists, removing first") warn(f" [{agent_name}] {dest_dir.name} уже есть — заменяю")
_remove_path(dest_dir) _remove_path(dest_dir)
try: try:
@@ -293,35 +331,126 @@ def _install_skill_dir(src_dir, dest_dir, agent_name):
except OSError: except OSError:
# Fallback to copy # Fallback to copy
shutil.copytree(src_dir, dest_dir, symlinks=True) shutil.copytree(src_dir, dest_dir, symlinks=True)
ok(f" [{agent_name}] → {dest_dir} (copied)") ok(f" [{agent_name}] → {dest_dir} (скопирован)")
def remove_skill(skill_name, target): def remove_skill(skill_name, target):
"""Remove a skill from target agent.""" """Remove a skill from target agent."""
targets = resolve_targets(target) targets = resolve_targets(target)
removed = 0
for t in targets: for t in targets:
if t == "hermes": if t == "hermes":
paths = list(HERMES_SKILLS.glob(f"*/{skill_name}")) + list(HERMES_SKILLS.glob(f"**/{skill_name}")) paths = list(HERMES_SKILLS.glob(f"*/{skill_name}")) + list(HERMES_SKILLS.glob(f"**/{skill_name}"))
for p in paths: for p in paths:
if p.exists(): if p.exists():
_remove_path(p) _remove_path(p)
ok(f" [hermes] removed {p}") ok(f" [hermes] удалён {p}")
removed += 1
elif t == "opencode": elif t == "opencode":
for skill_root in OPENCODE_SKILLS_DIRS: for skill_root in OPENCODE_SKILLS_DIRS:
dest = skill_root / skill_name dest = skill_root / skill_name
if dest.exists(): if dest.exists():
_remove_path(dest) _remove_path(dest)
ok(f" [opencode] removed {dest}") ok(f" [opencode] удалён {dest}")
removed += 1
if not removed:
warn(f"Скилл '{skill_name}' не найден у {', '.join(targets)}")
def list_skills(target): def _skill_dirs(roots):
"""List installed skills.""" """Yield каталоги с SKILL.md. Симлинки разворачиваются (os.walk followlinks)."""
for root in roots:
if not root.exists():
continue
seen = set()
for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
try:
st = os.stat(dirpath)
except OSError:
dirnames[:] = []
continue
key = (st.st_dev, st.st_ino)
if key in seen:
dirnames[:] = []
continue
seen.add(key)
if "SKILL.md" in filenames:
yield Path(dirpath)
def _skill_roots(target):
return [HERMES_SKILLS] if target == "hermes" else OPENCODE_SKILLS_DIRS
def installed_skill_names(target):
"""Имена скиллов, лежащих в каталогах агента (включая симлинки)."""
return {p.name for p in _skill_dirs(_skill_roots(target))}
def count_skills(target):
"""Сколько уникальных скиллов установлено у агента."""
return len(installed_skill_names(target))
def list_skills(target="all"):
"""Каталог: что доступно к установке/обновлению, и где уже стоит."""
targets = resolve_targets(target)
installed = {t: installed_skill_names(t) for t in targets}
print(c("Доступно из каталога", "bold"), c(f"({CATALOG_DIR})", "dim"))
print(c(" ✓ — установлено, · — нет", "dim"))
entries = catalog_entries()
if not entries:
print(" (каталог пуст)")
return
total = 0
have = 0
for name, entry, skills in entries:
sha, drift = source_git_state(entry)
tail = []
if sha:
tail.append(c(sha, "dim"))
if drift:
tail.append(c("⚠ сдвинут", "yellow"))
suffix = (" " + " ".join(tail)) if tail else ""
if skills:
print(f"\n {c(name, 'bold')} {c(str(len(skills)) + ' скиллов', 'dim')}{suffix}")
for s in skills:
marks = " ".join(
c(f"{t} ✓", "green") if s in installed[t] else c(f"{t} ·", "dim")
for t in targets
)
print(f" ├─ {c(s.ljust(28), 'cyan')} {marks}")
total += 1
if any(s in installed[t] for t in targets):
have += 1
elif (entry / "SKILL.md").exists():
nm = read_frontmatter_field(entry / "SKILL.md", "name") or name
print(f"\n {c(name, 'bold')} {c('SKILL.md в корне репы', 'yellow')}{suffix}")
print(f" ├─ {c(nm.ljust(28), 'cyan')} "
f"{c('ставится вручную', 'dim')}")
total += 1
if nm in installed.get("opencode", set()):
have += 1
else:
print(f"\n {c(name, 'bold')} {c('нет skills/', 'dim')}{suffix}")
print(f"\n В каталоге: {c(str(total), 'cyan')} установлено: {c(str(have), 'cyan')}")
if have < total:
print(c(f" Поставить: harness skills install <источник> <скилл>|--all", "dim"))
def list_skills_installed(target="all"):
"""Что реально установлено у агентов (включая не из каталога)."""
targets = resolve_targets(target) targets = resolve_targets(target)
for t in targets: for t in targets:
if t == "hermes": if t == "hermes":
print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):") print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):")
_print_skill_tree(HERMES_SKILLS) _print_skill_tree(HERMES_SKILLS)
elif t == "opencode": else:
for skill_root in OPENCODE_SKILLS_DIRS: for skill_root in OPENCODE_SKILLS_DIRS:
if skill_root.exists(): if skill_root.exists():
print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):") print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):")
@@ -586,7 +715,7 @@ def _parse_value(value):
return value return value
# ─── Install Agents Binaries ────────────────────────────────────── # ─── Binaries ─────────────────────────────────────────────────────
def install_opencode(): def install_opencode():
@@ -602,12 +731,11 @@ def install_opencode():
die(f"OpenCode install failed: {err}") die(f"OpenCode install failed: {err}")
def install_hermes(): def update_hermes():
info("Updating Hermes...") info("Updating Hermes...")
rc, out, err = run(["hermes", "update"], timeout=120) rc, out, err = run(["hermes", "update"], timeout=120)
if rc == 0: if rc == 0:
ok("Hermes updated") ok("Hermes updated")
# Restart services
restart_hermes() restart_hermes()
else: else:
warn(f"hermes update failed: {err}") warn(f"hermes update failed: {err}")
@@ -667,8 +795,7 @@ def show_status():
enabled = sum(1 for s in servers.values() if s.get("enabled", True)) enabled = sum(1 for s in servers.values() if s.get("enabled", True))
print(f" MCP servers: {c(str(len(servers)), 'cyan')} ({enabled} enabled)") 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(count_skills('hermes')), 'cyan')}")
print(f" Skills: {c(str(skill_count), 'cyan')}")
# OpenCode # OpenCode
print(f"\n{c('OpenCode', 'bold')}") print(f"\n{c('OpenCode', 'bold')}")
@@ -692,11 +819,7 @@ def show_status():
for svc in OPENCODE_SERVICES: for svc in OPENCODE_SERVICES:
_service_line(svc) _service_line(svc)
oc_skills = 0 print(f" Skills: {c(str(count_skills('opencode')), 'cyan')}")
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 oc_agents = len(list(OPENCODE_AGENTS_GLOBAL.glob("*.md"))) if OPENCODE_AGENTS_GLOBAL.exists() else 0
repo_agents = len(available_agents()) repo_agents = len(available_agents())
@@ -704,13 +827,13 @@ def show_status():
# Catalog # Catalog
print(f"\n{c('Catalog', 'bold')}") print(f"\n{c('Catalog', 'bold')}")
if CATALOG_DIR.exists(): entries = catalog_entries()
entries = [d for d in CATALOG_DIR.iterdir() if d.is_dir()] if entries:
print(f" Sources: {c(str(len(entries)), 'cyan')}") print(f" Sources: {c(str(len(entries)), 'cyan')}")
for e in entries: for name, entry, skills in entries:
skills_dir = e / "skills" sha, drift = source_git_state(entry)
n = len(list(skills_dir.glob("*/SKILL.md"))) if skills_dir.exists() else 0 mark = c(" ⚠ сдвинут", "yellow") if drift else ""
print(f" ├─ {c(e.name, 'cyan')} ({n} skills)") print(f" ├─ {c(name, 'cyan')} ({len(skills)} skills){mark}")
else: else:
print(f" {c('Not found', 'red')}") print(f" {c('Not found', 'red')}")
@@ -722,190 +845,196 @@ def show_status():
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Harness — Manager for Hermes & OpenCode skills, agents, MCP, and configs", description="Harness — менеджер скиллов, агентов, MCP и конфигов для Hermes и OpenCode",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Грамматика: объект — первым, действие — вторым.
Примеры:
harness status harness status
harness list catalog
harness list agents harness skills list
harness install skill jakubkrehel-skills better-ui --target all harness skills installed
harness install skill jakubkrehel-skills --all --target opencode harness skills install jakubkrehel-skills better-ui --target all
harness install agent leader --scope global harness skills install jakubkrehel-skills --all --target opencode
harness install agent --all --scope project --project ~/work/myrepo harness skills remove better-ui --target hermes
harness install opencode
harness configure hermes --set model.default=gpt-4o harness agents list
harness configure hermes mcp add my-server http://localhost:8000/mcp harness agents install leader --scope global
harness configure hermes mcp add github npx --env GITHUB_TOKEN=xxx --arg -y --arg @modelcontextprotocol/server-github harness agents install --all --scope project --project ~/work/myrepo
harness remove skill better-ui --target hermes harness agents remove leader
harness remove agent leader
harness update catalog harness mcp list
harness mcp add my-server http://localhost:8000/mcp
harness mcp add github npx --env GITHUB_TOKEN=xxx --arg -y --arg @modelcontextprotocol/server-github
harness mcp rm my-server
harness catalog list
harness catalog update
harness hermes config --set model.default=gpt-4o
harness hermes restart
harness hermes update
harness opencode install
harness opencode restart
""", """,
) )
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="object", required=True)
# install # ── status
install_parser = sub.add_parser("install", help="Install things") sub.add_parser("status", help="Обзор среды (сервисы, версии, счётчики)")
install_sub = install_parser.add_subparsers(dest="install_type", required=True)
# install skill # ── skills
skill_parser = install_sub.add_parser("skill") skills_p = sub.add_parser("skills", help="Скиллы из каталога")
skill_parser.add_argument("source", help="Catalog source name (e.g. jakubkrehel-skills)") skills_sub = skills_p.add_subparsers(dest="action", required=True, metavar="<действие>")
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) sk_list = skills_sub.add_parser("list", help="Что доступно в каталоге (+ где уже стоит)")
agent_parser = install_sub.add_parser("agent", help="Install OpenCode agent definition(s)") sk_list.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
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") sk_inst = skills_sub.add_parser("installed", help="Что реально установлено у агентов")
agent_parser.add_argument("--scope", default="global", choices=["global", "project", "all"], sk_inst.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
sk_add = skills_sub.add_parser("install", help="Установить скилл(ы) из каталога")
sk_add.add_argument("source", help="Источник в каталоге (напр. jakubkrehel-skills)")
sk_add.add_argument("skill", nargs="?", default=None, help="Имя скилла (или --all)")
sk_add.add_argument("--all", action="store_true", help="Все скиллы источника")
sk_add.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
sk_rm = skills_sub.add_parser("remove", help="Удалить скилл у агентов")
sk_rm.add_argument("name", help="Имя скилла")
sk_rm.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
# ── agents
agents_p = sub.add_parser("agents", help="Агенты (только OpenCode)")
agents_sub = agents_p.add_subparsers(dest="action", required=True, metavar="<действие>")
ag_list = agents_sub.add_parser("list", help="Доступные в репо и установленные агенты")
ag_list.add_argument("--scope", default="all", choices=["global", "project", "all"])
ag_list.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)")
ag_add = agents_sub.add_parser("install", help="Установить агента(ов)")
ag_add.add_argument("name", nargs="?", default=None, help="Имя агента (или --all)")
ag_add.add_argument("--all", action="store_true", help="Все агенты")
ag_add.add_argument("--scope", default="global", choices=["global", "project", "all"],
help="global → ~/.config/opencode/agents; project → <project>/.opencode/agents") help="global → ~/.config/opencode/agents; project → <project>/.opencode/agents")
agent_parser.add_argument("--project", default=None, help="Project root (default: cwd)") ag_add.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)")
# install opencode / hermes ag_rm = agents_sub.add_parser("remove", help="Удалить агента")
install_sub.add_parser("opencode", help="Install/update OpenCode binary") ag_rm.add_argument("name", help="Имя агента")
install_sub.add_parser("hermes", help="Update Hermes") ag_rm.add_argument("--scope", default="all", choices=["global", "project", "all"])
ag_rm.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)")
# list # ── mcp
list_parser = sub.add_parser("list", help="List installed items") mcp_p = sub.add_parser("mcp", help="MCP-серверы Hermes")
list_sub = list_parser.add_subparsers(dest="list_type", required=True) mcp_sub = mcp_p.add_subparsers(dest="action", required=True, metavar="<действие>")
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 mcp_sub.add_parser("list", help="Список MCP-серверов")
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 mcp_add_p = mcp_sub.add_parser("add", help="Добавить MCP-сервер (+ рестарт gateway)")
configure_parser = sub.add_parser("configure", help="Configure agents") mcp_add_p.add_argument("name")
configure_sub = configure_parser.add_subparsers(dest="configure_type", required=True) mcp_add_p.add_argument("source", help="URL или команда")
hermes_conf = configure_sub.add_parser("hermes", help="Configure Hermes") mcp_add_p.add_argument("--env", action="append", default=[], help="ENV=VAR (для stdio)")
hermes_conf_sub = hermes_conf.add_subparsers(dest="hermes_action") mcp_add_p.add_argument("--args", nargs="*", default=[], help="Доп. аргументы")
mcp_add_p.add_argument("--arg", "-a", dest="args_list", action="append", default=[],
help="Доп. аргумент (повторяемый)")
# configure hermes (plain — show or set) mcp_rm_p = mcp_sub.add_parser("rm", help="Удалить MCP-сервер (+ рестарт gateway)")
hermes_conf.add_argument("--set", "-s", action="append", default=[], mcp_rm_p.add_argument("name", help="Имя MCP-сервера")
help="key=value pairs (repeatable). Omit to show config.")
# configure hermes mcp # ── catalog
mcp_parser = hermes_conf_sub.add_parser("mcp", help="Manage Hermes MCP servers") cat_p = sub.add_parser("catalog", help="Каталог внешних репозиториев")
mcp_sub = mcp_parser.add_subparsers(dest="mcp_action", required=True) cat_sub = cat_p.add_subparsers(dest="action", required=True, metavar="<действие>")
mcp_add = mcp_sub.add_parser("add") cat_sub.add_parser("list", help="Источники каталога и их содержимое")
mcp_add.add_argument("name") cat_sub.add_parser("update", help="Обновить submodule'ы / клоны")
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") # ── hermes
mcp_rm.add_argument("name", help="MCP server name to remove") hermes_p = sub.add_parser("hermes", help="Hermes: конфиг и сервисы")
hermes_sub = hermes_p.add_subparsers(dest="action", required=True, metavar="<действие>")
h_conf = hermes_sub.add_parser("config", help="Показать или изменить конфиг")
h_conf.add_argument("--set", "-s", action="append", default=[],
help="key=value (повторяемо). Без него — показать конфиг.")
hermes_sub.add_parser("restart", help="Перезапустить gateway + webui")
hermes_sub.add_parser("update", help="hermes update + рестарт сервисов")
# restart # ── opencode
restart_parser = sub.add_parser("restart", help="Restart agents") oc_p = sub.add_parser("opencode", help="OpenCode: бинарник и сервисы")
restart_parser.add_argument("target", choices=["hermes", "opencode"], help="Agent to restart") oc_sub = oc_p.add_subparsers(dest="action", required=True, metavar="<действие>")
oc_sub.add_parser("install", help="Установить/обновить OpenCode")
# status oc_sub.add_parser("restart", help="Перезапустить opencode + openchamber")
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() args = parser.parse_args()
os.makedirs(CATALOG_DIR, exist_ok=True) os.makedirs(CATALOG_DIR, exist_ok=True)
# ── Commands ────────────────────────────────────────────── # ── Dispatch ──────────────────────────────────────────────
if args.command == "install": obj = args.object
if args.install_type == "skill": action = getattr(args, "action", None)
source = args.source
skill = args.skill
if obj == "status":
show_status()
elif obj == "skills":
if action == "list":
list_skills(args.target)
elif action == "installed":
list_skills_installed(args.target)
elif action == "install":
source, skill = args.source, args.skill
if "/" in source and not skill: if "/" in source and not skill:
source, skill = source.split("/", 1) source, skill = source.split("/", 1)
if args.all: if args.all:
install_all_skills(source, args.target) install_all_skills(source, args.target)
elif skill is None: elif skill is None:
die("Specify skill name: harness install skill <source> <skill> or use --all") die("Укажи имя скилла или --all: harness skills install <источник> <скилл>")
else: else:
install_skill(source, skill, args.target) install_skill(source, skill, args.target)
elif action == "remove":
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) 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": elif obj == "agents":
if args.configure_type == "hermes": if action == "list":
if hasattr(args, "hermes_action") and args.hermes_action == "mcp": list_agents(args.scope, args.project)
if args.mcp_action == "add": elif action == "install":
# Combine --args and --arg install_agent(args.name, args.all, args.scope, args.project)
all_args = args.args + args.args_list elif action == "remove":
remove_agent(args.name, args.scope, args.project)
elif obj == "mcp":
if action == "list":
list_mcp()
elif action == "add":
env_dict = {} env_dict = {}
for e in args.env: for e in args.env:
if "=" in e: if "=" in e:
k, v = e.split("=", 1) k, v = e.split("=", 1)
env_dict[k] = v env_dict[k] = v
mcp_add(args.name, args.source, env_vars=env_dict, args=all_args) mcp_add(args.name, args.source, env_vars=env_dict, args=args.args + args.args_list)
elif args.mcp_action == "rm": elif action == "rm":
mcp_rm(args.name) 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": elif obj == "catalog":
if args.target == "hermes": if action == "list":
restart_hermes() list_catalog()
else: elif action == "update":
restart_opencode()
elif args.command == "status":
show_status()
elif args.command == "update":
update_catalog() update_catalog()
elif obj == "hermes":
if action == "config":
configure_hermes(args.set)
elif action == "restart":
restart_hermes()
elif action == "update":
update_hermes()
elif obj == "opencode":
if action == "install":
install_opencode()
elif action == "restart":
restart_opencode()
if __name__ == "__main__": if __name__ == "__main__":
main() main()