feat: harness — менеджер скиллов, агентов и MCP для Hermes и OpenCode

- scripts/harness: CLI (skills, agents, MCP, конфиг Hermes, рестарт, статус)
- agents/: leader, fixer, researcher, tester + devops, admin
- catalog/jakubkrehel-skills: submodule → github.com/jakubkrehel/skills
- README: usage, форматы, таблица совместимости
This commit is contained in:
eka
2026-09-23 12:03:44 +05:00
parent 7970c3bb36
commit 13979a05ed
11 changed files with 1677 additions and 0 deletions
+911
View File
@@ -0,0 +1,911 @@
#!/usr/bin/env python3
"""
Harness — универсальный менеджер скиллов, агентов, MCP-серверов и конфигов
для Hermes и OpenCode.
Usage:
harness install skill <source> [--all] [--target opencode|hermes|all]
harness install agent <name> [--all] [--scope global|project] [--project DIR]
harness install opencode
harness install hermes
harness list skills [--target opencode|hermes]
harness list agents [--scope global|project|all] [--project DIR]
harness list mcp
harness list catalog
harness remove skill <name> [--target opencode|hermes|all]
harness remove agent <name> [--scope global|project|all] [--project DIR]
harness remove mcp <name>
harness configure hermes [--set key=value ...]
harness configure hermes mcp add <name> <url|command> [--env KEY=VAL] [--args ...]
harness configure hermes mcp rm <name>
harness restart <hermes|opencode>
harness status
harness update catalog
"""
import argparse
import os
import shutil
import subprocess
import sys
import yaml
from pathlib import Path
HARNESS_ROOT = Path(__file__).resolve().parent.parent
CATALOG_DIR = HARNESS_ROOT / "catalog"
AGENTS_SRC = HARNESS_ROOT / "agents"
HERMES_CONFIG = Path.home() / ".hermes" / "config.yaml"
HERMES_SKILLS = Path.home() / ".hermes" / "skills"
HERMES_SERVICES = ["hermes-gateway.service", "hermes-webui.service"]
OPENCODE_SERVICES = ["opencode.service", "openchamber.service"]
OPENCODE_SKILLS_DIRS = [
Path.home() / ".claude" / "skills",
Path.home() / ".config" / "opencode" / "skills",
]
OPENCODE_AGENTS_GLOBAL = Path.home() / ".config" / "opencode" / "agents"
COLOR = {
"green": "\033[92m",
"yellow": "\033[93m",
"red": "\033[91m",
"cyan": "\033[96m",
"dim": "\033[2m",
"bold": "\033[1m",
"reset": "\033[0m",
}
def c(text, *styles):
return "".join(COLOR.get(s, "") for s in styles) + text + COLOR["reset"]
def die(msg):
print(c("✗ ", "red") + msg, file=sys.stderr)
sys.exit(1)
def ok(msg):
print(c("✓ ", "green") + msg)
def info(msg):
print(c("ℹ ", "cyan") + msg)
def warn(msg):
print(c("⚠ ", "yellow") + msg)
# ─── Helpers ──────────────────────────────────────────────────────
def run(cmd, **kwargs):
"""Run a command and return (returncode, stdout, stderr)."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, **kwargs)
except FileNotFoundError:
return 127, "", f"command not found: {cmd[0]}"
return result.returncode, result.stdout.strip(), result.stderr.strip()
def systemctl(*args):
return run(["systemctl", "--user"] + list(args))
def load_yaml(path):
if path.exists():
with open(path) as f:
return yaml.safe_load(f) or {}
return {}
def dump_yaml(data, path):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
def read_frontmatter_field(path, field, width=None):
"""Прочитать одно поле из YAML frontmatter .md файла."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return ""
if not text.startswith("---"):
return ""
parts = text.split("---", 2)
if len(parts) < 2:
return ""
try:
fm = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError:
return ""
val = " ".join(str(fm.get(field, "")).split())
return val[:width] if width else val
# ─── Catalog ──────────────────────────────────────────────────────
def list_catalog():
"""Scan catalog/ for skill sources (git repos with skills/ dirs)."""
if not CATALOG_DIR.exists():
print(" (empty — no catalog entries)")
return
for entry in sorted(CATALOG_DIR.iterdir()):
if entry.is_dir():
skills_dir = entry / "skills"
if skills_dir.is_dir():
skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists())
print(f" {c(entry.name, 'bold')} ({len(skills)} skills)")
for s in skills:
print(f" ├─ {s}")
else:
print(f" {c(entry.name, 'bold')} (no skills/)")
# Check if it's just a git repo
if (entry / ".git").exists():
print(f" (git repo, no skills/ dir)")
def catalog_skill_dirs():
"""Yield (source_name, skill_name, skill_dir) for every skill in catalog."""
if not CATALOG_DIR.exists():
return
for entry in sorted(CATALOG_DIR.iterdir()):
if not entry.is_dir():
continue
skills_dir = entry / "skills"
if not skills_dir.is_dir():
continue
for skill_dir in skills_dir.iterdir():
if (skill_dir / "SKILL.md").exists():
yield entry.name, skill_dir.name, skill_dir
def _submodule_paths():
"""Пути submodule'ов из .gitmodules (относительно корня репы)."""
gitmodules = HARNESS_ROOT / ".gitmodules"
if not gitmodules.exists():
return set()
rc, out, err = run(["git", "config", "-f", str(gitmodules), "--get-regexp", r"^submodule\..*\.path$"])
paths = set()
if rc == 0:
for line in out.splitlines():
parts = line.split(None, 1)
if len(parts) == 2:
paths.add(parts[1].strip())
return paths
def update_catalog():
"""Обновить каталог: submodule'ы — через суперпроект, клоны — git pull."""
if not CATALOG_DIR.exists():
warn("catalog/ doesn't exist")
return
subs = _submodule_paths()
if subs:
info(f"Обновляю submodule'ы ({len(subs)})...")
rc, out, err = run(
["git", "submodule", "update", "--remote", "--init", "--recursive"],
cwd=HARNESS_ROOT,
)
if rc == 0:
rc2, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT)
for line in status.splitlines():
print(f" {line}")
ok("submodule'ы обновлены (изменения — закоммить отдельно)")
else:
warn(f"submodule update: {err[:200]}")
for entry in sorted(CATALOG_DIR.iterdir()):
if not entry.is_dir() or not (entry / ".git").exists():
continue
rel = str(entry.relative_to(HARNESS_ROOT))
if rel in subs:
continue
info(f"Updating {entry.name} (clone)...")
rc, out, err = run(["git", "pull", "--ff-only"], cwd=entry)
if rc == 0:
ok(f" {entry.name} updated")
else:
warn(f" {entry.name}: {err[:200]}")
# ─── Skills ───────────────────────────────────────────────────────
def install_skill(skill_source_name, skill_name, target):
"""Install a single skill from catalog to the target agent."""
# Find the skill
skill_dir = None
for entry in CATALOG_DIR.iterdir():
if entry.name == skill_source_name or (
skill_source_name in entry.name
):
candidate = entry / "skills" / skill_name
if (candidate / "SKILL.md").exists():
skill_dir = candidate
break
if not skill_dir:
# Try direct path
candidate = CATALOG_DIR / skill_source_name / "skills" / skill_name
if (candidate / "SKILL.md").exists():
skill_dir = candidate
if not skill_dir:
die(f"Skill '{skill_source_name}/{skill_name}' not found in catalog. "
f"Use 'harness list catalog' to see available skills.")
targets = resolve_targets(target)
for t in targets:
if t == "hermes":
dest = HERMES_SKILLS / "catalog" / skill_name
_install_skill_dir(skill_dir, dest, t)
elif t == "opencode":
for skill_root in OPENCODE_SKILLS_DIRS:
dest = skill_root / skill_name
_install_skill_dir(skill_dir, dest, t)
def install_all_skills(source_name, target):
"""Install every skill from a catalog source."""
source_dir = CATALOG_DIR / source_name
skills_dir = source_dir / "skills"
if not skills_dir.is_dir():
die(f"Source '{source_name}' not found in catalog, or has no skills/ directory")
skills = sorted(s.name for s in skills_dir.iterdir() if (s / "SKILL.md").exists())
if not skills:
die(f"No skills found in {source_name}/skills/")
info(f"Installing {len(skills)} skills from '{source_name}' → {target}")
for skill_name in skills:
install_skill(source_name, skill_name, target)
ok(f"All {len(skills)} skills installed")
def _remove_path(path):
"""Remove a path: symlink → unlink, directory → rmtree, file → unlink."""
if path.is_symlink() or not path.is_dir():
path.unlink()
elif path.is_dir():
shutil.rmtree(path, ignore_errors=True)
def _install_skill_dir(src_dir, dest_dir, agent_name):
"""Symlink or copy a skill directory to dest."""
dest_dir.parent.mkdir(parents=True, exist_ok=True)
if dest_dir.exists():
warn(f" [{agent_name}] {dest_dir.name} already exists, removing first")
_remove_path(dest_dir)
try:
os.symlink(str(src_dir.resolve()), str(dest_dir), target_is_directory=True)
ok(f" [{agent_name}] → {dest_dir}")
except OSError:
# Fallback to copy
shutil.copytree(src_dir, dest_dir, symlinks=True)
ok(f" [{agent_name}] → {dest_dir} (copied)")
def remove_skill(skill_name, target):
"""Remove a skill from target agent."""
targets = resolve_targets(target)
for t in targets:
if t == "hermes":
paths = list(HERMES_SKILLS.glob(f"*/{skill_name}")) + list(HERMES_SKILLS.glob(f"**/{skill_name}"))
for p in paths:
if p.exists():
_remove_path(p)
ok(f" [hermes] removed {p}")
elif t == "opencode":
for skill_root in OPENCODE_SKILLS_DIRS:
dest = skill_root / skill_name
if dest.exists():
_remove_path(dest)
ok(f" [opencode] removed {dest}")
def list_skills(target):
"""List installed skills."""
targets = resolve_targets(target)
for t in targets:
if t == "hermes":
print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):")
_print_skill_tree(HERMES_SKILLS)
elif t == "opencode":
for skill_root in OPENCODE_SKILLS_DIRS:
if skill_root.exists():
print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):")
_print_skill_tree(skill_root)
def _print_skill_tree(root, indent=""):
if not root.exists():
print(f"{indent} (not found)")
return
count = 0
for entry in sorted(root.iterdir()):
sk = entry / "SKILL.md"
if sk.exists():
name = entry.name
desc = read_frontmatter_field(sk, "description", 80)
sym = " → " + os.readlink(str(entry)) if entry.is_symlink() else ""
print(f"{indent} ├─ {c(name, 'cyan')}{c(sym, 'dim')}")
if desc:
print(f"{indent} │ {c(desc[:80], 'dim')}")
count += 1
elif entry.is_dir() and not entry.name.startswith("."):
sub = sum(1 for _ in entry.rglob("SKILL.md"))
if sub > 0:
print(f"{indent} {c(entry.name, 'yellow')}/")
_print_skill_tree(entry, indent + " ")
if count == 0 and indent == "":
print(f" (no skills installed)")
def resolve_targets(target):
if target == "all":
return ["hermes", "opencode"]
elif target in ("hermes", "opencode"):
return [target]
else:
die(f"Unknown target: {target}. Use hermes, opencode, or all.")
# ─── Agents (OpenCode) ────────────────────────────────────────────
#
# Агенты поддерживает только OpenCode. Hermes пользовательских агентов
# не имеет — у него только runtime-делегирование (delegate_task).
#
# OpenCode ищет определения в {agent,agents}/**/*.md внутри конфиг-каталогов:
# глобально: ~/.config/opencode/agents/<name>.md
# в проекте: <project>/.opencode/agents/<name>.md
# Имя агента = имя файла без .md. Источник — harness/agents/.
def available_agents():
"""Все .md агенты в репозитории (harness/agents/)."""
if not AGENTS_SRC.exists():
return []
return sorted(p for p in AGENTS_SRC.glob("*.md") if p.is_file())
def agent_dest_dirs(scope="global", project=None):
"""Куда ставятся агенты: список (label, dir)."""
dirs = []
if scope in ("all", "global"):
dirs.append(("opencode/global", OPENCODE_AGENTS_GLOBAL))
if scope in ("all", "project"):
root = Path(project).expanduser().resolve() if project else Path.cwd()
dirs.append(("opencode/project", root / ".opencode" / "agents"))
return dirs
def list_agents(scope="all", project=None):
"""Показать доступных в репо и установленных агентов."""
avail = available_agents()
print(f"{c('Available agents', 'bold')} ({AGENTS_SRC}):")
if not avail:
print(" (none)")
for p in avail:
mode = read_frontmatter_field(p, "mode") or "?"
desc = read_frontmatter_field(p, "description", 72)
print(f" ├─ {c(p.stem, 'cyan')} {c('[' + mode + ']', 'dim')}")
if desc:
print(f" │ {c(desc, 'dim')}")
for label, d in agent_dest_dirs(scope, project):
print(f"\n{c('Installed — ' + label, 'bold')} ({d}):")
if not d.exists():
print(" (dir not found)")
continue
items = sorted(d.glob("*.md"))
if not items:
print(" (none)")
for p in items:
sym = " → " + os.readlink(str(p)) if p.is_symlink() else ""
print(f" ├─ {c(p.stem, 'cyan')}{c(sym, 'dim')}")
def install_agent(name=None, install_all=False, scope="global", project=None):
"""Установить агента(ов) в каталоги OpenCode симлинком."""
avail = available_agents()
if not avail:
die(f"Нет агентов в {AGENTS_SRC}")
names = ", ".join(p.stem for p in avail)
if install_all:
sources = avail
else:
if not name:
die(f"Укажи имя агента или --all. Доступны: {names}")
src = AGENTS_SRC / f"{name}.md"
if not src.exists():
die(f"Агент '{name}' не найден. Доступны: {names}")
sources = [src]
dests = agent_dest_dirs(scope, project)
info(f"Устанавливаю {len(sources)} агент(ов) → {', '.join(l for l, _ in dests)}")
for src in sources:
for label, d in dests:
_link_agent(src, d / src.name, label)
ok(f"Готово: {len(sources)} агент(ов)")
def _link_agent(src, dest, label):
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists() or dest.is_symlink():
warn(f" [{label}] {dest.name} уже есть — заменяю")
_remove_path(dest)
try:
os.symlink(str(src.resolve()), str(dest))
ok(f" [{label}] → {dest}")
except OSError:
shutil.copy2(src, dest)
ok(f" [{label}] → {dest} (скопирован)")
def remove_agent(name, scope="all", project=None):
"""Удалить агента из каталогов OpenCode."""
removed = 0
for label, d in agent_dest_dirs(scope, project):
dest = d / f"{name}.md"
if dest.exists() or dest.is_symlink():
_remove_path(dest)
ok(f" [{label}] удалён {dest}")
removed += 1
if not removed:
warn(f"Агент '{name}' не найден в области '{scope}'")
# ─── Hermes MCP ───────────────────────────────────────────────────
def list_mcp():
config = load_yaml(HERMES_CONFIG)
servers = config.get("mcp_servers", {})
if not servers:
print(" No MCP servers configured")
return
print(f"{c('Hermes MCP Servers', 'bold')} ({HERMES_CONFIG}):")
for name, cfg in sorted(servers.items()):
enabled = cfg.get("enabled", True)
status = c("✓", "green") if enabled else c("✗", "red")
source = cfg.get("url") or " ".join(cfg.get("args", []))
print(f" {status} {c(name, 'cyan')} → {c(source, 'dim')}")
def mcp_add(name, source, env_vars=None, args=None):
config = load_yaml(HERMES_CONFIG)
if "mcp_servers" not in config:
config["mcp_servers"] = {}
if name in config["mcp_servers"]:
warn(f"MCP server '{name}' already exists, overwriting")
if source.startswith(("http://", "https://")):
entry = {"url": source, "enabled": True}
else:
entry = {"command": source, "args": args or [], "enabled": True}
if env_vars:
entry["env"] = env_vars
config["mcp_servers"][name] = entry
dump_yaml(config, HERMES_CONFIG)
ok(f"MCP server '{name}' added to Hermes config")
# Restart Hermes gateway to pick up changes
info("Restarting Hermes gateway to apply changes...")
rc, out, err = systemctl("restart", "hermes-gateway.service")
if rc == 0:
ok("Hermes gateway restarted")
else:
warn(f"Could not restart Hermes gateway: {err[:200]}")
def mcp_rm(name):
config = load_yaml(HERMES_CONFIG)
servers = config.get("mcp_servers", {})
if name not in servers:
die(f"MCP server '{name}' not found")
del servers[name]
config["mcp_servers"] = servers
dump_yaml(config, HERMES_CONFIG)
ok(f"MCP server '{name}' removed from Hermes config")
rc, out, err = systemctl("restart", "hermes-gateway.service")
if rc == 0:
ok("Hermes gateway restarted")
else:
warn(f"Could not restart Hermes gateway: {err[:200]}")
# ─── Hermes Configure ──────────────────────────────────────────────
def configure_hermes(kv_pairs=None):
"""Set key=value pairs in Hermes config."""
config = load_yaml(HERMES_CONFIG)
if not kv_pairs:
# Show current config
print(c("Hermes config", "bold"), f"({HERMES_CONFIG}):")
for key, val in config.items():
if key in ("mcp_servers",):
continue # handled separately
if isinstance(val, dict):
print(f" {c(key, 'cyan')}:")
for k, v in val.items():
print(f" {k}: {v}")
else:
print(f" {c(key, 'cyan')}: {val}")
return
for pair in kv_pairs:
if "=" not in pair:
warn(f"Skipping '{pair}' — expected key=value")
continue
key, value = pair.split("=", 1)
# Support dotted keys like "model.default=deepseek"
parts = key.split(".", 1)
if len(parts) == 2:
section, subkey = parts
if section not in config:
config[section] = {}
config[section][subkey] = _parse_value(value)
else:
config[key] = _parse_value(value)
ok(f"Set {key} = {value}")
dump_yaml(config, HERMES_CONFIG)
info("Restarting Hermes to apply config changes...")
systemctl("restart", "hermes-gateway.service")
systemctl("restart", "hermes-webui.service")
def _parse_value(value):
"""Parse YAML-like values."""
if value.lower() in ("true", "yes", "on"):
return True
if value.lower() in ("false", "no", "off"):
return False
if value.isdigit():
return int(value)
try:
return float(value)
except ValueError:
pass
return value
# ─── Install Agents Binaries ──────────────────────────────────────
def install_opencode():
info("Installing/updating OpenCode...")
install_script = "https://opencode.ai/install"
rc, out, err = run(
["bash", "-c", f"curl -fsSL {install_script} | bash"],
timeout=120,
)
if rc == 0:
ok("OpenCode installed/updated")
else:
die(f"OpenCode install failed: {err}")
def install_hermes():
info("Updating Hermes...")
rc, out, err = run(["hermes", "update"], timeout=120)
if rc == 0:
ok("Hermes updated")
# Restart services
restart_hermes()
else:
warn(f"hermes update failed: {err}")
info("Hermes is installed from git. Try: cd ~/.hermes/hermes-agent && git pull && pip install -e .")
# ─── Restart ──────────────────────────────────────────────────────
def restart_hermes():
for svc in HERMES_SERVICES:
rc, out, err = systemctl("restart", svc)
if rc == 0:
ok(f"{svc} restarted")
else:
warn(f"Failed to restart {svc}: {err[:200]}")
def restart_opencode():
for svc in OPENCODE_SERVICES:
rc, out, err = systemctl("restart", svc)
if rc == 0:
ok(f"{svc} restarted")
else:
warn(f"Failed to restart {svc}: {err[:200]}")
# ─── Status ────────────────────────────────────────────────────────
def _service_line(svc):
rc, out, err = systemctl("is-active", svc)
status = c("● running", "green") if rc == 0 else c("○ stopped", "red")
print(f" {status} {svc}")
def show_status():
print(c("═" * 50, "bold"))
print(c("Harness — Agent Environment Status", "bold"))
print(c("═" * 50, "bold"))
# Hermes
print(f"\n{c('Hermes Agent', 'bold')}")
hermes_path = shutil.which("hermes")
if hermes_path:
rc, out, err = run([hermes_path, "--version"])
if rc == 0:
print(f" Version: {c(out, 'cyan')}")
else:
print(f" {c('Not found', 'red')}")
for svc in HERMES_SERVICES:
_service_line(svc)
config = load_yaml(HERMES_CONFIG)
servers = config.get("mcp_servers", {})
enabled = sum(1 for s in servers.values() if s.get("enabled", True))
print(f" MCP servers: {c(str(len(servers)), 'cyan')} ({enabled} enabled)")
skill_count = sum(1 for _ in HERMES_SKILLS.rglob("SKILL.md")) if HERMES_SKILLS.exists() else 0
print(f" Skills: {c(str(skill_count), 'cyan')}")
# OpenCode
print(f"\n{c('OpenCode', 'bold')}")
opencode_path = shutil.which("opencode")
if opencode_path:
rc, out, err = run([opencode_path, "--version"])
if rc == 0:
print(f" Version: {c(out, 'cyan')}")
else:
print(f" Binary: {c(opencode_path, 'cyan')}")
else:
for candidate in ["/usr/local/bin/opencode", os.path.expanduser("~/.local/bin/opencode")]:
if os.path.exists(candidate):
opencode_path = candidate
break
if opencode_path:
print(f" Binary: {c(opencode_path, 'dim')} (on disk, not in PATH)")
else:
print(f" {c('Not installed', 'red')}")
for svc in OPENCODE_SERVICES:
_service_line(svc)
oc_skills = 0
for d in OPENCODE_SKILLS_DIRS:
if d.exists():
oc_skills += sum(1 for _ in d.rglob("SKILL.md"))
print(f" Skills: {c(str(oc_skills), 'cyan')}")
oc_agents = len(list(OPENCODE_AGENTS_GLOBAL.glob("*.md"))) if OPENCODE_AGENTS_GLOBAL.exists() else 0
repo_agents = len(available_agents())
print(f" Agents: {c(str(oc_agents), 'cyan')} (в репо: {c(str(repo_agents), 'cyan')})")
# Catalog
print(f"\n{c('Catalog', 'bold')}")
if CATALOG_DIR.exists():
entries = [d for d in CATALOG_DIR.iterdir() if d.is_dir()]
print(f" Sources: {c(str(len(entries)), 'cyan')}")
for e in entries:
skills_dir = e / "skills"
n = len(list(skills_dir.glob("*/SKILL.md"))) if skills_dir.exists() else 0
print(f" ├─ {c(e.name, 'cyan')} ({n} skills)")
else:
print(f" {c('Not found', 'red')}")
print()
# ─── Main CLI ─────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Harness — Manager for Hermes & OpenCode skills, agents, MCP, and configs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
harness status
harness list catalog
harness list agents
harness install skill jakubkrehel-skills better-ui --target all
harness install skill jakubkrehel-skills --all --target opencode
harness install agent leader --scope global
harness install agent --all --scope project --project ~/work/myrepo
harness install opencode
harness configure hermes --set model.default=gpt-4o
harness configure hermes mcp add my-server http://localhost:8000/mcp
harness configure hermes mcp add github npx --env GITHUB_TOKEN=xxx --arg -y --arg @modelcontextprotocol/server-github
harness remove skill better-ui --target hermes
harness remove agent leader
harness update catalog
""",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
sub = parser.add_subparsers(dest="command", required=True)
# install
install_parser = sub.add_parser("install", help="Install things")
install_sub = install_parser.add_subparsers(dest="install_type", required=True)
# install skill
skill_parser = install_sub.add_parser("skill")
skill_parser.add_argument("source", help="Catalog source name (e.g. jakubkrehel-skills)")
skill_parser.add_argument("skill", nargs="?", default=None, help="Skill name (or omit with --all)")
skill_parser.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
skill_parser.add_argument("--all", action="store_true", help="Install all skills from the source")
# install agent (OpenCode only)
agent_parser = install_sub.add_parser("agent", help="Install OpenCode agent definition(s)")
agent_parser.add_argument("name", nargs="?", default=None, help="Agent name (file name without .md)")
agent_parser.add_argument("--all", action="store_true", help="Install all agents")
agent_parser.add_argument("--scope", default="global", choices=["global", "project", "all"],
help="global → ~/.config/opencode/agents; project → <project>/.opencode/agents")
agent_parser.add_argument("--project", default=None, help="Project root (default: cwd)")
# install opencode / hermes
install_sub.add_parser("opencode", help="Install/update OpenCode binary")
install_sub.add_parser("hermes", help="Update Hermes")
# list
list_parser = sub.add_parser("list", help="List installed items")
list_sub = list_parser.add_subparsers(dest="list_type", required=True)
list_sub.add_parser("skills", help="List installed skills").add_argument(
"--target", default="all", choices=["hermes", "opencode", "all"]
)
list_agents_p = list_sub.add_parser("agents", help="List OpenCode agents")
list_agents_p.add_argument("--scope", default="all", choices=["global", "project", "all"])
list_agents_p.add_argument("--project", default=None, help="Project root (default: cwd)")
list_sub.add_parser("mcp", help="List MCP servers")
list_sub.add_parser("catalog", help="List catalog entries")
# remove
remove_parser = sub.add_parser("remove", help="Remove items")
remove_sub = remove_parser.add_subparsers(dest="remove_type", required=True)
rm_skill = remove_sub.add_parser("skill")
rm_skill.add_argument("name", help="Skill name to remove")
rm_skill.add_argument("--target", default="all", choices=["hermes", "opencode", "all"])
rm_agent = remove_sub.add_parser("agent")
rm_agent.add_argument("name", help="Agent name to remove")
rm_agent.add_argument("--scope", default="all", choices=["global", "project", "all"])
rm_agent.add_argument("--project", default=None, help="Project root (default: cwd)")
rm_mcp = remove_sub.add_parser("mcp")
rm_mcp.add_argument("name", help="MCP server name to remove")
# configure
configure_parser = sub.add_parser("configure", help="Configure agents")
configure_sub = configure_parser.add_subparsers(dest="configure_type", required=True)
hermes_conf = configure_sub.add_parser("hermes", help="Configure Hermes")
hermes_conf_sub = hermes_conf.add_subparsers(dest="hermes_action")
# configure hermes (plain — show or set)
hermes_conf.add_argument("--set", "-s", action="append", default=[],
help="key=value pairs (repeatable). Omit to show config.")
# configure hermes mcp
mcp_parser = hermes_conf_sub.add_parser("mcp", help="Manage Hermes MCP servers")
mcp_sub = mcp_parser.add_subparsers(dest="mcp_action", required=True)
mcp_add = mcp_sub.add_parser("add")
mcp_add.add_argument("name")
mcp_add.add_argument("source", help="URL or command")
mcp_add.add_argument("--env", action="append", default=[], help="ENV=VAR for command servers")
mcp_add.add_argument("--args", nargs="*", default=[], help="Extra args (e.g. -y @package)")
mcp_add.add_argument("--arg", "-a", dest="args_list", action="append", default=[],
help="Extra arg (repeatable, for npx packages)")
mcp_rm = mcp_sub.add_parser("rm")
mcp_rm.add_argument("name", help="MCP server name to remove")
# restart
restart_parser = sub.add_parser("restart", help="Restart agents")
restart_parser.add_argument("target", choices=["hermes", "opencode"], help="Agent to restart")
# status
sub.add_parser("status", help="Show status of everything")
# update
sub.add_parser("update", help="Update catalog").add_argument("type", nargs="?", default="catalog")
args = parser.parse_args()
os.makedirs(CATALOG_DIR, exist_ok=True)
# ── Commands ──────────────────────────────────────────────
if args.command == "install":
if args.install_type == "skill":
source = args.source
skill = args.skill
if "/" in source and not skill:
source, skill = source.split("/", 1)
if args.all:
install_all_skills(source, args.target)
elif skill is None:
die("Specify skill name: harness install skill <source> <skill> or use --all")
else:
install_skill(source, skill, args.target)
elif args.install_type == "agent":
install_agent(args.name, args.all, args.scope, args.project)
elif args.install_type == "opencode":
install_opencode()
elif args.install_type == "hermes":
install_hermes()
elif args.command == "list":
if args.list_type == "skills":
list_skills(args.target)
elif args.list_type == "agents":
list_agents(args.scope, args.project)
elif args.list_type == "mcp":
list_mcp()
elif args.list_type == "catalog":
list_catalog()
elif args.command == "remove":
if args.remove_type == "skill":
remove_skill(args.name, args.target)
elif args.remove_type == "agent":
remove_agent(args.name, args.scope, args.project)
elif args.remove_type == "mcp":
mcp_rm(args.name)
elif args.command == "configure":
if args.configure_type == "hermes":
if hasattr(args, "hermes_action") and args.hermes_action == "mcp":
if args.mcp_action == "add":
# Combine --args and --arg
all_args = args.args + args.args_list
env_dict = {}
for e in args.env:
if "=" in e:
k, v = e.split("=", 1)
env_dict[k] = v
mcp_add(args.name, args.source, env_vars=env_dict, args=all_args)
elif args.mcp_action == "rm":
mcp_rm(args.name)
else:
configure_hermes(args.set if hasattr(args, "set") else None)
else:
die(f"Configure target not supported: {args.configure_type}")
elif args.command == "restart":
if args.target == "hermes":
restart_hermes()
else:
restart_opencode()
elif args.command == "status":
show_status()
elif args.command == "update":
update_catalog()
if __name__ == "__main__":
main()