diff --git a/README.md b/README.md index 03028e3..3e1c5fd 100644 --- a/README.md +++ b/README.md @@ -6,44 +6,99 @@ ``` dev/harness/ -├── agents/ # Определения агентов OpenCode (leader, fixer, devops, admin, ...) +├── agents/ # Определения агентов OpenCode (leader, fixer, devops, admin, ...) ├── catalog/ # Скиллы: git submodule'ы на внешние репы │ ├── jakubkrehel-skills/ # → github.com/jakubkrehel/skills (11 скиллов UI) │ └── developer-documentation-skill/ # → NateBJones-Projects/… (доки; ставится вручную) ├── scripts/ -│ └── harness # Основной CLI-скрипт +│ └── harness # Основной CLI-скрипт └── 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 -./scripts/harness status # Статус всей среды -./scripts/harness list catalog # Что есть в каталоге скиллов -./scripts/harness list skills # Установленные скиллы -./scripts/harness list agents # Агенты: доступные в репо + установленные -./scripts/harness list mcp # MCP-серверы Hermes +./scripts/harness status # Обзор среды +./scripts/harness skills list # Что доступно в каталоге (+ отметки установки) +./scripts/harness skills installed # Что реально стоит у агентов +./scripts/harness agents list # Агенты: доступные в репо + установленные +./scripts/harness mcp list # MCP-серверы Hermes +./scripts/harness catalog list # Источники каталога ``` -## Команды +## `skills` — скиллы из каталога -### Установка скиллов +### `skills list` — что доступно к установке + +Показывает содержимое каталога и где скилл уже стоит. `✓` — установлено, `·` — нет. ```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 +./scripts/harness skills list +./scripts/harness skills list --target opencode ``` -Скиллы ставятся симлинками в соответствующие директории: +``` +Доступно из каталога (/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//` | | OpenCode | `~/.config/opencode/skills//` | -### Управление скиллами - -```bash -./scripts/harness list skills # Все скиллы -./scripts/harness list skills --target hermes # Только Hermes -./scripts/harness remove skill better-ui # Удалить отовсюду -./scripts/harness remove skill better-ui --target hermes -``` - -### Агенты (только OpenCode) +## `agents` — агенты (только 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 agents list +./scripts/harness agents list --scope global +./scripts/harness agents list --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 agents install leader # в ~/.config/opencode/agents/ +./scripts/harness agents install --all --scope global +./scripts/harness agents install devops --scope project --project ~/work/myrepo -# Удалить -./scripts/harness remove agent leader # отовсюду -./scripts/harness remove agent leader --scope global +./scripts/harness agents remove leader +./scripts/harness agents remove leader --scope global ``` | `--scope` | Куда ставится | @@ -89,60 +132,47 @@ dev/harness/ `mode: all` (кроме `leader` — `primary`), чтобы лидер мог вызывать их как сабагентов. > **Hermes агентов не поддерживает** — только runtime-делегирование через -> `delegate_task`. Пользовательских определений агентов у него нет, поэтому -> `install agent` работает только для OpenCode. +> `delegate_task`. Пользовательских определений агентов у него нет. -### MCP-серверы (Hermes) +## `mcp` — MCP-серверы Hermes ```bash -# Добавить MCP по URL (HTTP SSE) -./scripts/harness configure hermes mcp add my-server http://localhost:8000/mcp +./scripts/harness mcp list -# Добавить MCP через команду (STDIO) -./scripts/harness configure hermes mcp add github npx \ +# По URL (HTTP/SSE) +./scripts/harness mcp add my-server http://localhost:8000/mcp + +# Командой (stdio) +./scripts/harness 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 +./scripts/harness mcp rm my-server ``` -### Конфигурация Hermes +`add` и `rm` сами перезапускают `hermes-gateway`, чтобы изменения подхватились. + +## `catalog` — внешние репозитории ```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 +./scripts/harness catalog list # источники, содержимое, sha, признак «сдвинут» +./scripts/harness catalog update # git submodule update --remote / git pull для клонов ``` -После изменения конфига скрипт автоматически перезапускает Hermes. - -### Установка/обновление бинарников +## `hermes` / `opencode` — сам агент ```bash -./scripts/harness install opencode # curl-установщик opencode.ai -./scripts/harness install hermes # hermes update + рестарт сервисов +./scripts/harness hermes config # показать конфиг +./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 ``` -### Перезапуск - -```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/ -``` +`hermes config --set` меняет `~/.hermes/config.yaml` и перезапускает сервисы. ## Как добавить скилл в каталог @@ -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" ``` -После этого `harness list catalog` покажет все найденные скиллы, а -`harness update catalog` подтянет свежие коммиты во всех submodule'ах. - -Обычный клон (`git clone` внутрь `catalog/`) тоже работает — `update catalog` -обновит его через `git pull`, но такой каталог не попадёт в коммит репы -(вложенная репа не станет submodule автоматически). +После этого `harness skills list` покажет найденные скиллы, а +`harness catalog update` подтянет свежие коммиты во всех submodule'ах. ### Раскладка репозитория и ручная установка -CLI `harness` понимает одну раскладку — `<репа>/skills//SKILL.md`. -Репозитории с другой структурой он показывает как `(no skills/)`, и их скиллы -ставятся вручную. Ограничение только у CLI: сам OpenCode сканирует рекурсивно -(`skills/**/SKILL.md`, а через `skills.paths` — `**/SKILL.md` на любой глубине). +CLI понимает одну раскладку — `<репа>/skills//SKILL.md`. Репозитории с другой +структурой (`SKILL.md` в корне, `.agents/skills/`, вложенные категории) он показывает +без списка скиллов, и такие скиллы ставятся вручную. Ограничение только у CLI: сам +OpenCode сканирует рекурсивно (`skills/**/SKILL.md`, а через `skills.paths` — +`**/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: @@ -215,9 +242,7 @@ permission: ## Поддерживаемые форматы - **Скиллы** — `SKILL.md` с YAML frontmatter (`name`, `description`); родной формат OpenCode и Hermes. - Скиллы из `catalog/*/skills//SKILL.md` видны через `harness list catalog`. - **Агенты** — `.md` с frontmatter (`description`, `mode`, ...); формат OpenCode. - Лежат в `agents/.md`. ## Совместимость diff --git a/scripts/harness b/scripts/harness index e3b117f..b967eb7 100755 --- a/scripts/harness +++ b/scripts/harness @@ -1,26 +1,28 @@ #!/usr/bin/env python3 """ -Harness — универсальный менеджер скиллов, агентов, MCP-серверов и конфигов +Harness — менеджер скиллов, агентов, MCP-серверов и конфигов для Hermes и OpenCode. -Usage: - harness install skill [--all] [--target opencode|hermes|all] - harness install agent [--all] [--scope global|project] [--project DIR] - harness install opencode - harness install hermes - harness list skills [--target opencode|hermes] - harness list agents [--scope global|project|all] [--project DIR] - harness list mcp - harness list catalog - harness remove skill [--target opencode|hermes|all] - harness remove agent [--scope global|project|all] [--project DIR] - harness remove mcp - harness configure hermes [--set key=value ...] - harness configure hermes mcp add [--env KEY=VAL] [--args ...] - harness configure hermes mcp rm - harness restart +Грамматика: объект — первым, действие — вторым. + harness status - harness update catalog + harness skills list [--target hermes|opencode|all] + harness skills installed [--target ...] + harness skills install [] [--all] [--target ...] + harness skills remove [--target ...] + harness agents list [--scope global|project|all] [--project DIR] + harness agents install |--all [--scope ...] [--project DIR] + harness agents remove [--scope ...] [--project DIR] + harness mcp list + harness mcp add [--env KEY=VAL] [--arg ...] + harness mcp rm + 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 @@ -130,39 +132,27 @@ def read_frontmatter_field(path, field, width=None): # ─── Catalog ────────────────────────────────────────────────────── -def list_catalog(): - """Scan catalog/ for skill sources (git repos with skills/ dirs).""" +def catalog_entries(): + """Источники каталога: [(name, dir, [skill_names])], отсортировано.""" + entries = [] 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 + return entries 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 + names = [] + if skills_dir.is_dir(): + names = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists()) + entries.append((entry.name, entry, names)) + 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(): @@ -180,6 +170,54 @@ def _submodule_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(): """Обновить каталог: submodule'ы — через суперпроект, клоны — git pull.""" if not CATALOG_DIR.exists(): @@ -239,8 +277,8 @@ def install_skill(skill_source_name, skill_name, target): 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.") + die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. " + f"Что есть — 'harness skills list'.") targets = resolve_targets(target) @@ -259,16 +297,16 @@ def install_all_skills(source_name, target): 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") + die(f"Источник '{source_name}' не найден в каталоге, или у него нет skills/") 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/") + 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: install_skill(source_name, skill_name, target) - ok(f"All {len(skills)} skills installed") + ok(f"Готово: {len(skills)} скиллов") 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) 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) try: @@ -293,35 +331,126 @@ def _install_skill_dir(src_dir, dest_dir, agent_name): except OSError: # Fallback to copy 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): """Remove a skill from target agent.""" targets = resolve_targets(target) + removed = 0 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}") + ok(f" [hermes] удалён {p}") + removed += 1 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}") + ok(f" [opencode] удалён {dest}") + removed += 1 + if not removed: + warn(f"Скилл '{skill_name}' не найден у {', '.join(targets)}") -def list_skills(target): - """List installed skills.""" +def _skill_dirs(roots): + """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) for t in targets: if t == "hermes": print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):") _print_skill_tree(HERMES_SKILLS) - elif t == "opencode": + else: for skill_root in OPENCODE_SKILLS_DIRS: if skill_root.exists(): print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):") @@ -586,7 +715,7 @@ def _parse_value(value): return value -# ─── Install Agents Binaries ────────────────────────────────────── +# ─── Binaries ───────────────────────────────────────────────────── def install_opencode(): @@ -602,12 +731,11 @@ def install_opencode(): die(f"OpenCode install failed: {err}") -def install_hermes(): +def update_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}") @@ -667,8 +795,7 @@ def show_status(): 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')}") + print(f" Skills: {c(str(count_skills('hermes')), 'cyan')}") # OpenCode print(f"\n{c('OpenCode', 'bold')}") @@ -692,11 +819,7 @@ def show_status(): 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')}") + print(f" Skills: {c(str(count_skills('opencode')), 'cyan')}") oc_agents = len(list(OPENCODE_AGENTS_GLOBAL.glob("*.md"))) if OPENCODE_AGENTS_GLOBAL.exists() else 0 repo_agents = len(available_agents()) @@ -704,13 +827,13 @@ def show_status(): # Catalog print(f"\n{c('Catalog', 'bold')}") - if CATALOG_DIR.exists(): - entries = [d for d in CATALOG_DIR.iterdir() if d.is_dir()] + entries = catalog_entries() + if entries: 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)") + for name, entry, skills in entries: + sha, drift = source_git_state(entry) + mark = c(" ⚠ сдвинут", "yellow") if drift else "" + print(f" ├─ {c(name, 'cyan')} ({len(skills)} skills){mark}") else: print(f" {c('Not found', 'red')}") @@ -722,190 +845,196 @@ def show_status(): def main(): parser = argparse.ArgumentParser( - description="Harness — Manager for Hermes & OpenCode skills, agents, MCP, and configs", + description="Harness — менеджер скиллов, агентов, MCP и конфигов для Hermes и OpenCode", 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 + + harness skills list + harness skills installed + harness skills install jakubkrehel-skills better-ui --target all + harness skills install jakubkrehel-skills --all --target opencode + harness skills remove better-ui --target hermes + + harness agents list + harness agents install leader --scope global + harness agents install --all --scope project --project ~/work/myrepo + harness agents remove leader + + 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") - sub = parser.add_subparsers(dest="command", required=True) + sub = parser.add_subparsers(dest="object", required=True) - # install - install_parser = sub.add_parser("install", help="Install things") - install_sub = install_parser.add_subparsers(dest="install_type", required=True) + # ── status + sub.add_parser("status", help="Обзор среды (сервисы, версии, счётчики)") - # 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") + # ── skills + skills_p = sub.add_parser("skills", help="Скиллы из каталога") + skills_sub = skills_p.add_subparsers(dest="action", required=True, metavar="<действие>") - # install agent (OpenCode only) - agent_parser = install_sub.add_parser("agent", help="Install OpenCode agent definition(s)") - agent_parser.add_argument("name", nargs="?", default=None, help="Agent name (file name without .md)") - agent_parser.add_argument("--all", action="store_true", help="Install all agents") - agent_parser.add_argument("--scope", default="global", choices=["global", "project", "all"], - help="global → ~/.config/opencode/agents; project → /.opencode/agents") - agent_parser.add_argument("--project", default=None, help="Project root (default: cwd)") + sk_list = skills_sub.add_parser("list", help="Что доступно в каталоге (+ где уже стоит)") + sk_list.add_argument("--target", default="all", choices=["hermes", "opencode", "all"]) - # install opencode / hermes - install_sub.add_parser("opencode", help="Install/update OpenCode binary") - install_sub.add_parser("hermes", help="Update Hermes") + sk_inst = skills_sub.add_parser("installed", help="Что реально установлено у агентов") + sk_inst.add_argument("--target", default="all", choices=["hermes", "opencode", "all"]) - # 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") + 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"]) - # 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") + 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"]) - # 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") + # ── agents + agents_p = sub.add_parser("agents", help="Агенты (только OpenCode)") + agents_sub = agents_p.add_subparsers(dest="action", required=True, metavar="<действие>") - # configure hermes (plain — show or set) - hermes_conf.add_argument("--set", "-s", action="append", default=[], - help="key=value pairs (repeatable). Omit to show config.") + 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)") - # 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)") + 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 → /.opencode/agents") + ag_add.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)") - mcp_rm = mcp_sub.add_parser("rm") - mcp_rm.add_argument("name", help="MCP server name to remove") + ag_rm = agents_sub.add_parser("remove", help="Удалить агента") + ag_rm.add_argument("name", help="Имя агента") + ag_rm.add_argument("--scope", default="all", choices=["global", "project", "all"]) + ag_rm.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)") - # restart - restart_parser = sub.add_parser("restart", help="Restart agents") - restart_parser.add_argument("target", choices=["hermes", "opencode"], help="Agent to restart") + # ── mcp + mcp_p = sub.add_parser("mcp", help="MCP-серверы Hermes") + mcp_sub = mcp_p.add_subparsers(dest="action", required=True, metavar="<действие>") - # status - sub.add_parser("status", help="Show status of everything") + mcp_sub.add_parser("list", help="Список MCP-серверов") - # update - sub.add_parser("update", help="Update catalog").add_argument("type", nargs="?", default="catalog") + mcp_add_p = mcp_sub.add_parser("add", help="Добавить MCP-сервер (+ рестарт gateway)") + mcp_add_p.add_argument("name") + mcp_add_p.add_argument("source", help="URL или команда") + mcp_add_p.add_argument("--env", action="append", default=[], help="ENV=VAR (для stdio)") + mcp_add_p.add_argument("--args", nargs="*", default=[], help="Доп. аргументы") + mcp_add_p.add_argument("--arg", "-a", dest="args_list", action="append", default=[], + help="Доп. аргумент (повторяемый)") + + mcp_rm_p = mcp_sub.add_parser("rm", help="Удалить MCP-сервер (+ рестарт gateway)") + mcp_rm_p.add_argument("name", help="Имя MCP-сервера") + + # ── catalog + cat_p = sub.add_parser("catalog", help="Каталог внешних репозиториев") + cat_sub = cat_p.add_subparsers(dest="action", required=True, metavar="<действие>") + cat_sub.add_parser("list", help="Источники каталога и их содержимое") + cat_sub.add_parser("update", help="Обновить submodule'ы / клоны") + + # ── hermes + 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 + рестарт сервисов") + + # ── opencode + oc_p = sub.add_parser("opencode", help="OpenCode: бинарник и сервисы") + oc_sub = oc_p.add_subparsers(dest="action", required=True, metavar="<действие>") + oc_sub.add_parser("install", help="Установить/обновить OpenCode") + oc_sub.add_parser("restart", help="Перезапустить opencode + openchamber") args = parser.parse_args() os.makedirs(CATALOG_DIR, exist_ok=True) - # ── Commands ────────────────────────────────────────────── + # ── Dispatch ────────────────────────────────────────────── - if args.command == "install": - if args.install_type == "skill": - source = args.source - skill = args.skill + obj = args.object + action = getattr(args, "action", None) + 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: source, skill = source.split("/", 1) - if args.all: install_all_skills(source, args.target) elif skill is None: - die("Specify skill name: harness install skill or use --all") + die("Укажи имя скилла или --all: harness skills install <источник> <скилл>") 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": + elif action == "remove": remove_skill(args.name, args.target) - elif args.remove_type == "agent": + + elif obj == "agents": + if action == "list": + list_agents(args.scope, args.project) + elif action == "install": + install_agent(args.name, args.all, args.scope, args.project) + elif action == "remove": remove_agent(args.name, args.scope, args.project) - elif args.remove_type == "mcp": + + elif obj == "mcp": + if action == "list": + list_mcp() + elif action == "add": + 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=args.args + args.args_list) + elif action == "rm": 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 obj == "catalog": + if action == "list": + list_catalog() + elif action == "update": + update_catalog() - elif args.command == "restart": - if args.target == "hermes": + elif obj == "hermes": + if action == "config": + configure_hermes(args.set) + elif action == "restart": restart_hermes() - else: + elif action == "update": + update_hermes() + + elif obj == "opencode": + if action == "install": + install_opencode() + elif action == "restart": restart_opencode() - elif args.command == "status": - show_status() - - elif args.command == "update": - update_catalog() - if __name__ == "__main__": main()