#!/usr/bin/env python3
"""
Harness — менеджер скиллов, агентов, MCP-серверов и конфигов
для Hermes и OpenCode.

Грамматика: объект — первым, действие — вторым.

  harness status
  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 init
  harness catalog update
  harness hermes config [--set key=value ...]
  harness hermes restart
  harness hermes update
  harness opencode install
  harness opencode restart
"""

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


def short_path(path):
    """~ вместо домашнего каталога."""
    p = str(path)
    home = str(Path.home())
    return "~" + p[len(home):] if p.startswith(home) else p


def plural(n, one, few, many):
    """Русская форма по числу: 1 скилл, 2 скилла, 5 скиллов."""
    n = abs(int(n)) % 100
    if 11 <= n <= 14:
        return many
    n %= 10
    if n == 1:
        return one
    if 2 <= n <= 4:
        return few
    return many


# ─── Catalog ──────────────────────────────────────────────────────


def catalog_entries():
    """Источники каталога: [(name, dir, [skill_names])], отсортировано."""
    entries = []
    if not CATALOG_DIR.exists():
        return entries
    for entry in sorted(CATALOG_DIR.iterdir()):
        if not entry.is_dir():
            continue
        skills_dir = entry / "skills"
        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():
    """Пути 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 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():
    """Источники каталога. Состав каждого — в `skills list`."""
    subs = _submodule_state()
    rows = []
    uninit = []
    total_skills = 0
    for name, entry, skills in catalog_entries():
        rel = str(entry.relative_to(HARNESS_ROOT))
        if subs.get(rel) == "uninit":
            uninit.append(name)
            rows.append((None, name, "не инициализирован", None, None, None))
            continue

        sha, drift = source_git_state(entry)
        if skills:
            n = len(skills)
            meta = f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}"
            total_skills += n
        elif (entry / "SKILL.md").exists():
            meta = "SKILL.md в корне"
            total_skills += 1
        else:
            meta = "пусто"
        if sha:
            meta += f", {sha}"
        rows.append((None, name, meta, None, None, "⚠ сдвинут" if drift else None))

    footer = (f"Итого: {len(rows)} {plural(len(rows), 'источник', 'источника', 'источников')}, "
              f"{total_skills} {plural(total_skills, 'скилл', 'скилла', 'скиллов')}")
    if uninit:
        footer += "\nСтянуть: harness catalog init"

    render_listing(
        "КАТАЛОГ — источники",
        f"Каталог: {CATALOG_DIR}",
        rows,
        footer=footer,
        notice=f"submodule'ы не стянуты: {', '.join(uninit)}" if uninit else None,
    )


def _submodule_state():
    """Состояние submodule'ов: {путь: 'ok' | 'uninit' | 'drift'}.

    Опирается на префикс в `git submodule status`:
    '-' не инициализирован, '+' ушёл от зафиксированного коммита.
    """
    if not (HARNESS_ROOT / ".gitmodules").exists():
        return {}
    rc, out, err = run(["git", "submodule", "status"], cwd=HARNESS_ROOT)
    if rc != 0:
        return {}
    state = {}
    for line in out.splitlines():
        line = line.rstrip()
        if not line:
            continue
        flag, rest = line[0], line[1:]
        if flag not in "-+U":
            flag, rest = " ", line
        parts = rest.split()
        if len(parts) >= 2:
            state[parts[1]] = {"-": "uninit", "+": "drift"}.get(flag, "ok")
    return state


def _print_submodule_status():
    rc, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT)
    if rc == 0:
        for line in status.splitlines():
            print(f"    {line}")


def init_catalog():
    """Стянуть submodule'ы на зафиксированных в репе коммитах."""
    if not CATALOG_DIR.exists():
        warn("catalog/ doesn't exist")
        return

    subs = _submodule_paths()
    if not subs:
        warn("в .gitmodules нет источников — каталог пуст")
        info(f"Добавь: git submodule add <url> {CATALOG_DIR}/<имя>")
        return

    state = _submodule_state()
    if state and not any(s == "uninit" for s in state.values()):
        info(f"Все источники на месте ({len(subs)})")
        _print_submodule_status()
        return

    info(f"Стягиваю источники ({len(subs)}) на зафиксированных коммитах...")
    rc, out, err = run(["git", "submodule", "update", "--init", "--recursive"], cwd=HARNESS_ROOT)
    if rc == 0:
        _print_submodule_status()
        ok("каталог готов")
    else:
        warn(f"submodule update --init: {err[:200]}")
        info("Если клонировали без сабмодулей — проверь доступ к их URL")


def update_catalog():
    """Обновить каталог: стянуть недостающее и подтянуть свежие коммиты upstream."""
    if not CATALOG_DIR.exists():
        warn("catalog/ doesn't exist")
        return

    subs = _submodule_paths()

    if subs:
        state = _submodule_state()
        if any(s == "uninit" for s in state.values()):
            info("Часть источников не стянута — сначала инициализирую...")
            run(["git", "submodule", "update", "--init", "--recursive"], cwd=HARNESS_ROOT)

        info(f"Обновляю источники ({len(subs)}) до актуальных коммитов upstream...")
        rc, out, err = run(
            ["git", "submodule", "update", "--remote", "--recursive"],
            cwd=HARNESS_ROOT,
        )
        if rc == 0:
            _print_submodule_status()
            ok("источники обновлены (изменения — закоммить отдельно)")
        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:
        hint = "Что есть — 'harness skills list'."
        if any(s == "uninit" for s in _submodule_state().values()):
            hint = "Источники каталога не стянуты — выполни: harness catalog init"
        die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. {hint}")

    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_name}' не найден в каталоге, или у него нет skills/")

    skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists())
    if not skills:
        die(f"В {source_name}/skills/ нет скиллов")

    info(f"Устанавливаю {len(skills)} скиллов из '{source_name}' → {target}")
    for skill_name in skills:
        install_skill(source_name, skill_name, target)
    ok(f"Готово: {len(skills)} скиллов")


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} уже есть — заменяю")
        _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} (скопирован)")


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] удалён {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] удалён {dest}")
                    removed += 1
    if not removed:
        warn(f"Скилл '{skill_name}' не найден у {', '.join(targets)}")


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))


# ─── Единый вид списков ───────────────────────────────────────────
#
# Правила вывода (обязательны для всех списочных команд):
#   1. Заголовок ВЕРХНИМ РЕГИСТРОМ: "<ОБЪЕКТ> — что именно".
#   2. Вторая строка (dim) — источник: "Каталог: <путь>" / "Источник: <путь>".
#   3. Есть отметки установки → строка-легенда "✓ — установлено, · — нет".
#   4. Группы — жёлтым, метаданные группы в скобках; между группами пустая строка.
#   5. Элемент: "├─ <имя>" (cyan, выровнено), затем мета (dim, выровнена),
#      затем отметки "<цель> ✓" / "<цель> ·", затем пометка (yellow).
#   6. Описание — строкой ниже, dim, обрезано до 72 символов.
#   7. Итог: "Итого: N <единица>, установлено M".
#   8. Всё на русском, без вкраплений "Available"/"Installed".
#   9. Порядок отметок = порядок целей из --target / --scope.
#  10. Числа — со склонением через plural(): 1 скилл, 2 скилла, 5 скиллов.

MARK_ON = "✓"
MARK_OFF = "·"
DESC_WIDTH = 72


def trim_line(s):
    """rstrip с учётом завершающего ANSI-reset (иначе хвостовые пробелы остаются)."""
    reset = COLOR["reset"]
    if s.endswith(reset):
        return s[: -len(reset)].rstrip() + reset
    return s.rstrip()


def render_listing(title, source, rows, targets=(), footer=None, dests=(), notice=None):
    """Единый вид списков «доступно к установке».

    rows: список кортежей (group, name, meta, marks, desc, note)
      group — заголовок группы или None
      meta  — короткая мета справа от имени (или None)
      marks — {цель: bool} или None
      desc  — описание строкой ниже (или None)
      note  — пометка жёлтым в конце строки (или None)
    dests: [(цель, путь)] — куда ставится, dim-строками после итога
    notice: предупреждение жёлтым сразу под легендой (или None)
    """
    print(c(title, "bold"))
    if source:
        print(c("  " + source, "dim"))
    if targets:
        print(c(f"  {MARK_ON} — установлено, {MARK_OFF} — нет", "dim"))
    if notice:
        print(c("  ⚠ " + notice, "yellow"))
    print()

    if not rows:
        print("  (пусто)")
    else:
        width_name = max(len(r[1]) for r in rows)
        metas = [r[2] or "" for r in rows]
        width_meta = max(len(m) for m in metas) if any(metas) else 0

        prev_group = None
        printed = False
        for group, name, meta, marks, desc, note in rows:
            if group != prev_group:
                if printed:
                    print()
                if group:
                    print(f"  {c(group, 'yellow')}")
                prev_group = group
            indent = "    " if group else "  "

            line = f"{indent}├─ {c(name.ljust(width_name), 'cyan')}"
            if width_meta:
                line += "  " + c((meta or "").ljust(width_meta), "dim")
            if marks:
                line += "  " + "  ".join(
                    c(f"{t} {MARK_ON}", "green") if marks.get(t) else c(f"{t} {MARK_OFF}", "dim")
                    for t in targets
                )
            if note:
                line += "  " + c(note, "yellow")
            print(trim_line(line))
            if desc:
                print(f"{indent}│  {c(desc[:DESC_WIDTH], 'dim')}")
            printed = True

    if footer:
        print()
        for line in footer.splitlines():
            print("  " + line)
        for label, path in dests:
            print(c(f"  {label:<18s} {path}", "dim"))


def list_skills(target="all"):
    """Каталог: что доступно к установке, и где уже стоит."""
    targets = resolve_targets(target)
    installed = {t: installed_skill_names(t) for t in targets}
    subs = _submodule_state()

    rows = []
    uninit = []
    total = 0
    have = 0
    for name, entry, skills in catalog_entries():
        rel = str(entry.relative_to(HARNESS_ROOT))
        if subs.get(rel) == "uninit":
            uninit.append(name)
            rows.append((None, name, "не инициализирован", None, None, None))
            continue

        sha, drift = source_git_state(entry)
        bits = []
        if skills:
            n = len(skills)
            bits.append(f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}")
        if sha:
            bits.append(sha)
        if drift:
            bits.append("⚠ сдвинут")
        group = name + (f"  ({', '.join(bits)})" if bits else "")

        if skills:
            for s in skills:
                marks = {t: s in installed[t] for t in targets}
                desc = read_frontmatter_field(entry / "skills" / s / "SKILL.md", "description")
                rows.append((group, s, None, marks, desc, None))
                total += 1
                if any(marks.values()):
                    have += 1
        elif (entry / "SKILL.md").exists():
            nm = read_frontmatter_field(entry / "SKILL.md", "name") or name
            desc = read_frontmatter_field(entry / "SKILL.md", "description")
            rows.append((group, nm, None, None, desc, "ставится вручную"))
            total += 1
            if nm in installed.get("opencode", set()):
                have += 1
        else:
            rows.append((group, "(нет SKILL.md)", None, None, None, "нечего ставить"))
            total += 1

    notice = None
    if uninit:
        notice = (f"submodule'ы не стянуты: {', '.join(uninit)}")
    elif not rows:
        notice = f"каталог пуст — добавь источник: git submodule add <url> {CATALOG_DIR}"

    footer = f"Итого: {total} {plural(total, 'скилл', 'скилла', 'скиллов')}, установлено {have}"
    if uninit:
        footer += "\nСтянуть: harness catalog init"
    elif have < total:
        footer += "\nПоставить: harness skills install <источник> <скилл>|--all"

    render_listing(
        "СКИЛЛЫ — доступно к установке",
        f"Каталог: {CATALOG_DIR}",
        rows,
        targets,
        footer=footer,
        notice=notice,
        dests=[
            ("hermes", short_path(HERMES_SKILLS / "catalog")),
            ("opencode", "  ".join(short_path(d) for d in OPENCODE_SKILLS_DIRS)),
        ],
    )


def list_skills_installed(target="all"):
    """Что реально установлено у агентов (включая не из каталога)."""
    targets = resolve_targets(target)
    roots = {"hermes": [HERMES_SKILLS], "opencode": OPENCODE_SKILLS_DIRS}
    for t in targets:
        print(c(f"УСТАНОВЛЕНО — СКИЛЛЫ ({t})", "bold"))
        existing = [r for r in roots[t] if r.exists()]
        if not existing:
            print(c("  (каталоги не найдены)", "dim"))
            print()
            continue
        for root in existing:
            print(c(f"  {root}", "dim"))
            _print_skill_tree(root)
        print()


def _print_skill_tree(root, indent=""):
    if not root.exists():
        print(f"{indent}  (не найдено)")
        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")
            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[:DESC_WIDTH], '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"  (ничего не установлено)")


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):
    """Доступные в репо и установленные агенты — единый вид со списком скиллов."""
    dests = agent_dest_dirs(scope, project)
    targets = [label for label, _ in dests]
    installed = {
        label: ({p.stem for p in d.glob("*.md")} if d.exists() else set())
        for label, d in dests
    }

    avail = available_agents()
    rows = []
    have = 0
    for p in avail:
        marks = {t: p.stem in installed[t] for t in targets}
        rows.append((
            None,
            p.stem,
            read_frontmatter_field(p, "mode") or "?",
            marks,
            read_frontmatter_field(p, "description"),
            None,
        ))
        if any(marks.values()):
            have += 1

    render_listing(
        "АГЕНТЫ — доступно к установке",
        f"Источник: {AGENTS_SRC}",
        rows,
        targets,
        footer=f"Итого: {len(avail)} {plural(len(avail), 'агент', 'агента', 'агентов')}, установлено {have}",
        dests=[(label, short_path(d)) for label, d in dests],
    )


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


# ─── 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 update_hermes():
    info("Updating Hermes...")
    rc, out, err = run(["hermes", "update"], timeout=120)
    if rc == 0:
        ok("Hermes updated")
        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)")

    print(f"  Skills: {c(str(count_skills('hermes')), '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)

    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())
    print(f"  Agents: {c(str(oc_agents), 'cyan')} (в репо: {c(str(repo_agents), 'cyan')})")

    # Catalog
    print(f"\n{c('Catalog', 'bold')}")
    entries = catalog_entries()
    if entries:
        print(f"  Sources: {c(str(len(entries)), 'cyan')}")
        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')}")

    print()


# ─── Main CLI ─────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(
        description="Harness — менеджер скиллов, агентов, MCP и конфигов для Hermes и OpenCode",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Грамматика: объект — первым, действие — вторым.

Примеры:
  harness status

  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 init
  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="object", required=True)

    # ── status
    sub.add_parser("status", help="Обзор среды (сервисы, версии, счётчики)")

    # ── skills
    skills_p = sub.add_parser("skills", help="Скиллы из каталога")
    skills_sub = skills_p.add_subparsers(dest="action", required=True, metavar="<действие>")

    sk_list = skills_sub.add_parser("list", help="Что доступно в каталоге (+ где уже стоит)")
    sk_list.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])

    sk_inst = skills_sub.add_parser("installed", help="Что реально установлено у агентов")
    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")
    ag_add.add_argument("--project", default=None, help="Корень проекта (по умолчанию cwd)")

    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)")

    # ── mcp
    mcp_p = sub.add_parser("mcp", help="MCP-серверы Hermes")
    mcp_sub = mcp_p.add_subparsers(dest="action", required=True, metavar="<действие>")

    mcp_sub.add_parser("list", help="Список MCP-серверов")

    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("init", help="Стянуть submodule'ы на зафиксированных коммитах")
    cat_sub.add_parser("update", help="Подтянуть свежие коммиты upstream (потом закоммитить)")

    # ── 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)

    # ── Dispatch ──────────────────────────────────────────────

    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("Укажи имя скилла или --all: harness skills install <источник> <скилл>")
            else:
                install_skill(source, skill, args.target)
        elif action == "remove":
            remove_skill(args.name, args.target)

    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 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 obj == "catalog":
        if action == "list":
            list_catalog()
        elif action == "init":
            init_catalog()
        elif action == "update":
            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__":
    main()
