From 8ab8d82930dea3cfa587b1ba2107c7685d4ba1d9 Mon Sep 17 00:00:00 2001 From: PattrnData Date: Sun, 28 Jun 2026 14:27:39 +0100 Subject: [PATCH] Add Hermes lazy Agency router plugin (#614) * Add Hermes lazy agency router plugin * Document Hermes router specialist usage --- .gitignore | 2 + integrations/hermes/README.md | 60 ++++ scripts/build-hermes-plugin.py | 482 +++++++++++++++++++++++++++++++++ scripts/convert.sh | 13 +- scripts/install.sh | 139 +++++++++- tools.json | 3 +- 6 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 integrations/hermes/README.md create mode 100644 scripts/build-hermes-plugin.py diff --git a/.gitignore b/.gitignore index 2c813018..a5bd4a72 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ integrations/kimi/*/ !integrations/kimi/README.md integrations/codex/agents/* integrations/osaurus/agency-*/ +integrations/hermes/agency-agents-router/ +graphify-out/ diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md new file mode 100644 index 00000000..7f6f77bc --- /dev/null +++ b/integrations/hermes/README.md @@ -0,0 +1,60 @@ +# Hermes Agency Agents Router Plugin + +Generated by `scripts/convert.sh --tool hermes`. + +This integration installs one Hermes plugin named `agency-agents-router` instead +of adding 232+ generated skills to `skills.external_dirs`. Hermes sees a +small fixed tool surface at startup, while the complete Agency roster is +stored on disk in `data/agents.json` and searched/loaded lazily. + +Generated agent count: 232 + +## Tools exposed to Hermes + +- `agency_agents_search` — find matching specialists by query/division. +- `agency_agents_inspect` — inspect one specialist's metadata or full body. +- `agency_agents_load` — compose one specialist prompt for the current task. +- `agency_agents_delegate` — delegate through Hermes `delegate_task` when available. + +## Specialist usage instruction for Hermes + +When a Hermes project needs Agency specialists, explicitly ask Hermes to use +the `agency-agents-router` plugin/router and load only the specialists needed for +the current phase. Do not ask Hermes to install or preload the full Agency +roster as skills. + +Recommended project instruction: + +```text +Use the agency-agents-router plugin. Search the Agency roster for the right +specialists, then load or delegate only the specific agents needed for each +part of the project. For multi-discipline projects, use multiple selected +specialists across the project, but keep routing lazy: do not preload the +full Agency roster and do not add agency-agents to skills.external_dirs. +``` + +Example: + +```text +For this Data Swami build, use the agency-agents-router plugin to pick +relevant Agency specialists. Search first, then delegate to selected agents +such as frontend, backend, UX, QA, data engineering, and product strategy as +needed. Load/delegate each specialist on demand rather than loading all +Agency agents at startup. +``` + +## Install + +```bash +./scripts/convert.sh --tool hermes +./scripts/install.sh --tool hermes +``` + +The installer copies the generated plugin to: + +```text +${HERMES_HOME:-~/.hermes}/plugins/agency-agents-router +``` + +It then enables `agency-agents-router` under `plugins.enabled` in the Hermes +config. It does **not** write to `skills.external_dirs`. diff --git a/scripts/build-hermes-plugin.py b/scripts/build-hermes-plugin.py new file mode 100644 index 00000000..0f001cbc --- /dev/null +++ b/scripts/build-hermes-plugin.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Build the Hermes lazy-router plugin for The Agency agents. + +The generated plugin exposes a small fixed tool surface to Hermes and keeps the +large agent roster in an on-disk JSON data file. That avoids using +skills.external_dirs, which advertises every Agency agent in Hermes' initial +skill catalog. +""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import textwrap +from pathlib import Path + +AGENT_DIRS = [ + "academic", + "design", + "engineering", + "finance", + "game-development", + "gis", + "marketing", + "paid-media", + "product", + "project-management", + "sales", + "security", + "spatial-computing", + "specialized", + "support", + "testing", +] + +PLUGIN_NAME = "agency-agents-router" + + +def slugify(value: str) -> str: + value = value.lower() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") + + +def parse_agent(path: Path, repo_root: Path) -> dict[str, str] | None: + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return None + parts = text.split("---\n", 2) + if len(parts) < 3: + return None + frontmatter = parts[1] + body = parts[2].lstrip("\n") + fields: dict[str, str] = {} + for line in frontmatter.splitlines(): + if ":" not in line or line.startswith((" ", "\t")): + continue + key, value = line.split(":", 1) + fields[key.strip()] = value.strip().strip('"').strip("'") + name = fields.get("name", "").strip() + if not name: + return None + rel = path.relative_to(repo_root) + division = rel.parts[0] + return { + "slug": slugify(name), + "name": name, + "description": fields.get("description", "").strip(), + "division": division, + "color": fields.get("color", "").strip(), + "emoji": fields.get("emoji", "").strip(), + "vibe": fields.get("vibe", "").strip(), + "source_path": str(rel), + "body": body, + } + + +def collect_agents(repo_root: Path) -> list[dict[str, str]]: + agents: list[dict[str, str]] = [] + for dirname in AGENT_DIRS: + base = repo_root / dirname + if not base.is_dir(): + continue + for path in sorted(base.rglob("*.md")): + parsed = parse_agent(path, repo_root) + if parsed: + agents.append(parsed) + agents.sort(key=lambda item: (item["division"], item["slug"])) + seen: set[str] = set() + duplicates: set[str] = set() + for agent in agents: + slug = agent["slug"] + if slug in seen: + duplicates.add(slug) + seen.add(slug) + if duplicates: + dupes = ", ".join(sorted(duplicates)) + raise SystemExit(f"duplicate Hermes agent slugs: {dupes}") + return agents + + +def plugin_yaml() -> str: + return textwrap.dedent( + f""" + name: {PLUGIN_NAME} + version: 1.0.0 + description: Lazy search/load/delegate router for The Agency agent roster. + provides_tools: + - agency_agents_search + - agency_agents_inspect + - agency_agents_load + - agency_agents_delegate + """ + ).lstrip() + + +def init_py() -> str: + return r'''"""Hermes plugin: lazy router for The Agency agents.""" +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Any + +_DATA_PATH = Path(__file__).parent / "data" / "agents.json" +_AGENTS: list[dict[str, Any]] | None = None + +_WORD_RE = re.compile(r"[a-z0-9][a-z0-9+.#_-]*", re.I) + + +def _load_agents() -> list[dict[str, Any]]: + global _AGENTS + if _AGENTS is None: + _AGENTS = json.loads(_DATA_PATH.read_text(encoding="utf-8")) + return _AGENTS + + +def _tokens(text: str) -> set[str]: + return {token.lower() for token in _WORD_RE.findall(text or "")} + + +def _agent_lookup(identifier: str) -> dict[str, Any] | None: + needle = (identifier or "").strip().lower() + if not needle: + return None + slug = re.sub(r"[^a-z0-9]+", "-", needle).strip("-") + for agent in _load_agents(): + if agent["slug"] == slug or agent["name"].lower() == needle: + return agent + return None + + +def _score(agent: dict[str, Any], query_tokens: set[str], query_text: str) -> float: + haystack_fields = [ + agent.get("name", ""), + agent.get("description", ""), + agent.get("division", ""), + agent.get("vibe", ""), + agent.get("body", "")[:8000], + ] + haystack_text = "\n".join(haystack_fields).lower() + haystack_tokens = _tokens(haystack_text) + overlap = query_tokens & haystack_tokens + score = float(len(overlap)) + if query_text and query_text in haystack_text: + score += 5.0 + name = agent.get("name", "").lower() + description = agent.get("description", "").lower() + for token in query_tokens: + if token in name: + score += 3.0 + if token in description: + score += 1.5 + if score == 0.0: + return 0.0 + # Slightly prefer focused descriptions over huge bodies when scores tie. + return score + (1.0 / math.sqrt(max(len(haystack_tokens), 1))) + + +def _summary(agent: dict[str, Any], score: float | None = None) -> dict[str, Any]: + item = { + "slug": agent["slug"], + "name": agent["name"], + "division": agent["division"], + "description": agent.get("description", ""), + "vibe": agent.get("vibe", ""), + "source_path": agent.get("source_path", ""), + } + if score is not None: + item["score"] = round(score, 3) + return item + + +def _specialist_prompt(agent: dict[str, Any], task: str = "") -> str: + task_block = f"\n\n## User task\n{task.strip()}\n" if task and task.strip() else "" + return ( + f"Use the following Agency specialist context for this turn. " + f"Adopt the specialist's relevant standards and checklists, but obey the " + f"user's current request and higher-priority system/developer instructions.\n\n" + f"# {agent['name']} ({agent['slug']})\n\n" + f"Division: {agent.get('division', '')}\n" + f"Description: {agent.get('description', '')}\n" + f"Source: {agent.get('source_path', '')}\n" + f"{task_block}\n\n" + f"## Specialist instructions\n{agent.get('body', '')}" + ) + + +def _json(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2) + + +SEARCH_DESCRIPTION = ( + "Search The Agency's on-disk specialist agent roster without loading all " + "agents into the prompt. Use this when the user asks for an Agency/Data " + "Swami specialist, role, discipline, or wants help choosing the right agent." +) +SEARCH_SCHEMA = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Natural-language search query."}, + "division": {"type": "string", "description": "Optional division filter, e.g. engineering, marketing, testing."}, + "limit": {"type": "integer", "description": "Maximum results, default 8, max 25."}, + }, + "required": ["query"], +} + +READ_DESCRIPTION = ( + "Read one Agency specialist by slug or name. Returns metadata by default " + "and includes the full specialist instructions only when include_body is true." +) +READ_SCHEMA = { + "type": "object", + "properties": { + "agent": {"type": "string", "description": "Agent slug or exact display name."}, + "include_body": {"type": "boolean", "description": "Include full specialist instructions."}, + }, + "required": ["agent"], +} + +PROMPT_DESCRIPTION = ( + "Load a selected Agency specialist as a prompt block for the current task. " + "Use after agency_agents_search when you need one specialist's full context." +) +PROMPT_SCHEMA = { + "type": "object", + "properties": { + "agent": {"type": "string", "description": "Agent slug or exact display name."}, + "task": {"type": "string", "description": "The user's task to pair with the specialist context."}, + }, + "required": ["agent"], +} + +DELEGATE_DESCRIPTION = ( + "Delegate a task to one selected Agency specialist through Hermes' " + "delegate_task tool when available. Falls back to returning the composed " + "specialist prompt if delegation is unavailable." +) +DELEGATE_SCHEMA = { + "type": "object", + "properties": { + "agent": {"type": "string", "description": "Agent slug or exact display name."}, + "task": {"type": "string", "description": "Concrete task for the specialist."}, + "toolsets": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional Hermes toolsets for the delegated worker, e.g. ['terminal','file'].", + }, + }, + "required": ["agent", "task"], +} + + +def register(ctx): + def search(args: dict[str, Any], **kwargs) -> str: + del kwargs + query = str(args.get("query", "")).strip() + if not query: + return _json({"success": False, "error": "query is required"}) + division = str(args.get("division", "")).strip().lower() + try: + limit = min(max(int(args.get("limit", 8)), 1), 25) + except Exception: + limit = 8 + q_tokens = _tokens(query) + q_text = query.lower() + matches: list[tuple[float, dict[str, Any]]] = [] + for agent in _load_agents(): + if division and agent.get("division", "").lower() != division: + continue + score = _score(agent, q_tokens, q_text) + if score > 0: + matches.append((score, agent)) + matches.sort(key=lambda item: (-item[0], item[1]["division"], item[1]["slug"])) + return _json({ + "success": True, + "query": query, + "count": len(matches), + "results": [_summary(agent, score) for score, agent in matches[:limit]], + }) + + def read(args: dict[str, Any], **kwargs) -> str: + del kwargs + agent = _agent_lookup(str(args.get("agent", ""))) + if not agent: + return _json({"success": False, "error": "agent not found", "agent": args.get("agent")}) + payload = {"success": True, "agent": _summary(agent)} + if bool(args.get("include_body", False)): + payload["body"] = agent.get("body", "") + return _json(payload) + + def prompt(args: dict[str, Any], **kwargs) -> str: + del kwargs + agent = _agent_lookup(str(args.get("agent", ""))) + if not agent: + return _json({"success": False, "error": "agent not found", "agent": args.get("agent")}) + return _json({ + "success": True, + "agent": _summary(agent), + "prompt": _specialist_prompt(agent, str(args.get("task", ""))), + }) + + def delegate(args: dict[str, Any], **kwargs) -> str: + del kwargs + agent = _agent_lookup(str(args.get("agent", ""))) + task = str(args.get("task", "")).strip() + if not agent: + return _json({"success": False, "error": "agent not found", "agent": args.get("agent")}) + if not task: + return _json({"success": False, "error": "task is required"}) + composed = _specialist_prompt(agent, task) + delegate_args: dict[str, Any] = { + "goal": task, + "context": composed, + } + toolsets = args.get("toolsets") + if isinstance(toolsets, list) and toolsets: + delegate_args["toolsets"] = [str(item) for item in toolsets] + try: + result = ctx.dispatch_tool("delegate_task", delegate_args) + return _json({"success": True, "agent": _summary(agent), "delegated": True, "result": result}) + except Exception as exc: # pragma: no cover - depends on Hermes runtime + return _json({ + "success": True, + "agent": _summary(agent), + "delegated": False, + "warning": f"delegate_task unavailable: {exc}", + "prompt": composed, + }) + + ctx.register_tool( + name="agency_agents_search", + toolset="agency_agents", + schema=SEARCH_SCHEMA, + handler=search, + description=SEARCH_DESCRIPTION, + ) + ctx.register_tool( + name="agency_agents_inspect", + toolset="agency_agents", + schema=READ_SCHEMA, + handler=read, + description=READ_DESCRIPTION, + ) + ctx.register_tool( + name="agency_agents_load", + toolset="agency_agents", + schema=PROMPT_SCHEMA, + handler=prompt, + description=PROMPT_DESCRIPTION, + ) + ctx.register_tool( + name="agency_agents_delegate", + toolset="agency_agents", + schema=DELEGATE_SCHEMA, + handler=delegate, + description=DELEGATE_DESCRIPTION, + ) +''' + + +def readme(agent_count: int) -> str: + return textwrap.dedent( + f""" + # Hermes Agency Agents Router Plugin + + Generated by `scripts/convert.sh --tool hermes`. + + This integration installs one Hermes plugin named `{PLUGIN_NAME}` instead + of adding 232+ generated skills to `skills.external_dirs`. Hermes sees a + small fixed tool surface at startup, while the complete Agency roster is + stored on disk in `data/agents.json` and searched/loaded lazily. + + Generated agent count: {agent_count} + + ## Tools exposed to Hermes + + - `agency_agents_search` — find matching specialists by query/division. + - `agency_agents_inspect` — inspect one specialist's metadata or full body. + - `agency_agents_load` — compose one specialist prompt for the current task. + - `agency_agents_delegate` — delegate through Hermes `delegate_task` when available. + + ## Specialist usage instruction for Hermes + + When a Hermes project needs Agency specialists, explicitly ask Hermes to use + the `{PLUGIN_NAME}` plugin/router and load only the specialists needed for + the current phase. Do not ask Hermes to install or preload the full Agency + roster as skills. + + Recommended project instruction: + + ```text + Use the agency-agents-router plugin. Search the Agency roster for the right + specialists, then load or delegate only the specific agents needed for each + part of the project. For multi-discipline projects, use multiple selected + specialists across the project, but keep routing lazy: do not preload the + full Agency roster and do not add agency-agents to skills.external_dirs. + ``` + + Example: + + ```text + For this Data Swami build, use the agency-agents-router plugin to pick + relevant Agency specialists. Search first, then delegate to selected agents + such as frontend, backend, UX, QA, data engineering, and product strategy as + needed. Load/delegate each specialist on demand rather than loading all + Agency agents at startup. + ``` + + ## Install + + ```bash + ./scripts/convert.sh --tool hermes + ./scripts/install.sh --tool hermes + ``` + + The installer copies the generated plugin to: + + ```text + ${{HERMES_HOME:-~/.hermes}}/plugins/{PLUGIN_NAME} + ``` + + It then enables `{PLUGIN_NAME}` under `plugins.enabled` in the Hermes + config. It does **not** write to `skills.external_dirs`. + """ + ).lstrip() + + +def build(repo_root: Path, out_dir: Path) -> int: + agents = collect_agents(repo_root) + plugin_dir = out_dir / PLUGIN_NAME + if plugin_dir.exists(): + shutil.rmtree(plugin_dir) + (plugin_dir / "data").mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text(plugin_yaml(), encoding="utf-8") + (plugin_dir / "__init__.py").write_text(init_py(), encoding="utf-8") + (plugin_dir / "data" / "agents.json").write_text( + json.dumps(agents, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (out_dir / "README.md").write_text(readme(len(agents)), encoding="utf-8") + return len(agents) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--out", type=Path, default=None, help="Output directory, default integrations/hermes") + args = parser.parse_args() + repo_root = args.repo_root.resolve() + out_dir = (args.out or (repo_root / "integrations" / "hermes")).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + count = build(repo_root, out_dir) + print(count) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/convert.sh b/scripts/convert.sh index 55d4e6b7..afbccf78 100755 --- a/scripts/convert.sh +++ b/scripts/convert.sh @@ -21,6 +21,7 @@ # kimi — Kimi Code CLI agent files (~/.config/kimi/agents/) # codex — Codex custom agent TOML files (~/.codex/agents/*.toml) # osaurus — Osaurus skill files (~/.osaurus/skills//SKILL.md) +# hermes — Hermes lazy-router plugin (one plugin + on-disk agent index) # all — All tools (default) # # Output is written to integrations// relative to the repo root. @@ -548,6 +549,12 @@ run_conversions() { local tool="$1" local count=0 + if [[ "$tool" == "hermes" ]]; then + clean_tool_output "$tool" + python3 "$SCRIPT_DIR/build-hermes-plugin.py" --repo-root "$REPO_ROOT" --out "$OUT_DIR/hermes" + return + fi + clean_tool_output "$tool" for dir in "${AGENT_DIRS[@]}"; do @@ -604,7 +611,7 @@ main() { esac done - local valid_tools=("antigravity" "gemini-cli" "opencode" "cursor" "aider" "windsurf" "openclaw" "qwen" "kimi" "codex" "osaurus" "all") + local valid_tools=("antigravity" "gemini-cli" "opencode" "cursor" "aider" "windsurf" "openclaw" "qwen" "kimi" "codex" "osaurus" "hermes" "all") local valid=false for t in "${valid_tools[@]}"; do [[ "$t" == "$tool" ]] && valid=true && break; done if ! $valid; then @@ -623,7 +630,7 @@ main() { local tools_to_run=() if [[ "$tool" == "all" ]]; then - tools_to_run=("antigravity" "gemini-cli" "opencode" "cursor" "aider" "windsurf" "openclaw" "qwen" "kimi" "codex" "osaurus") + tools_to_run=("antigravity" "gemini-cli" "opencode" "cursor" "aider" "windsurf" "openclaw" "qwen" "kimi" "codex" "osaurus" "hermes") else tools_to_run=("$tool") fi @@ -634,7 +641,7 @@ main() { if $use_parallel && [[ "$tool" == "all" ]]; then # Tools that write to separate dirs can run in parallel; buffer output so each tool's output stays together - local parallel_tools=(antigravity gemini-cli opencode cursor openclaw qwen codex osaurus) + local parallel_tools=(antigravity gemini-cli opencode cursor openclaw qwen codex osaurus hermes) local parallel_out_dir parallel_out_dir="$(mktemp -d)" info "Converting: ${#parallel_tools[@]}/${n_tools} tools in parallel (output buffered per tool)..." diff --git a/scripts/install.sh b/scripts/install.sh index 62c2e07c..947d54f3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -24,6 +24,7 @@ # qwen -- Copy SubAgents to ~/.qwen/agents/ (user-wide) or .qwen/agents/ (project) # codex -- Copy custom agent TOML files to ~/.codex/agents/ # osaurus -- Copy skills to ~/.osaurus/skills/ +# hermes -- Copy lazy-router plugin to ~/.hermes/plugins/ and enable it # all -- Install for all detected tools (default) # # Selection (compose freely; empty = everything): @@ -48,7 +49,7 @@ # # Env: CLAUDE_CONFIG_DIR, COPILOT_AGENT_DIR, CURSOR_RULES_DIR, GEMINI_AGENTS_DIR, # OPENCODE_AGENTS_DIR, OPENCLAW_DIR, QWEN_AGENTS_DIR, CODEX_AGENTS_DIR, -# OSAURUS_SKILLS_DIR +# OSAURUS_SKILLS_DIR, HERMES_HOME, HERMES_PLUGIN_DIR # override default install paths (checked before hardcoded defaults). # # --- USAGE-END --- (sentinel for usage(); do not remove) @@ -127,7 +128,7 @@ INTEGRATIONS="$REPO_ROOT/integrations" # shellcheck source=lib.sh . "$SCRIPT_DIR/lib.sh" -ALL_TOOLS=(claude-code copilot antigravity gemini-cli opencode openclaw cursor aider windsurf qwen kimi codex osaurus) +ALL_TOOLS=(claude-code copilot antigravity gemini-cli opencode openclaw cursor aider windsurf qwen kimi codex osaurus hermes) # Directories scanned for installable agents. Intentionally includes strategy/ # (its frontmatter-less NEXUS docs are filtered out by is_agent_file at scan time); @@ -262,6 +263,7 @@ resolve_dest() { qwen) var="QWEN_AGENTS_DIR" ;; codex) var="CODEX_AGENTS_DIR" ;; osaurus) var="OSAURUS_SKILLS_DIR" ;; + hermes) var="HERMES_PLUGIN_DIR" ;; esac if [[ -n "$var" && -n "${!var:-}" ]]; then printf '%s' "${!var}"; else printf '%s' "$def"; fi } @@ -274,7 +276,7 @@ resolve_tool_path() { opencode) bin="opencode" ;; openclaw) bin="openclaw" ;; cursor) bin="cursor" ;; aider) bin="aider" ;; windsurf) bin="windsurf" ;; qwen) bin="qwen" ;; kimi) bin="kimi" ;; codex) bin="codex" ;; antigravity) bin="" ;; - osaurus) bin="osaurus" ;; + osaurus) bin="osaurus" ;; hermes) bin="hermes" ;; esac [[ -n "$bin" ]] && command -v "$bin" 2>/dev/null } @@ -372,6 +374,7 @@ detect_qwen() { command -v qwen >/dev/null 2>&1 || [[ -d "${HOME}/.qwen" detect_kimi() { command -v kimi >/dev/null 2>&1; } detect_codex() { command -v codex >/dev/null 2>&1 || [[ -d "${HOME}/.codex" ]]; } detect_osaurus() { command -v osaurus >/dev/null 2>&1 || [[ -d "${HOME}/.osaurus" ]]; } +detect_hermes() { command -v hermes >/dev/null 2>&1 || [[ -d "${HERMES_HOME:-${HOME}/.hermes}" ]]; } is_detected() { case "$1" in @@ -388,6 +391,7 @@ is_detected() { kimi) detect_kimi ;; codex) detect_codex ;; osaurus) detect_osaurus ;; + hermes) detect_hermes ;; *) return 1 ;; esac } @@ -408,6 +412,7 @@ tool_label() { kimi) printf "%-14s %s" "Kimi Code" "(~/.config/kimi/agents)" ;; codex) printf "%-14s %s" "Codex" "(~/.codex/agents)" ;; osaurus) printf "%-14s %s" "Osaurus" "(~/.osaurus/skills)" ;; + hermes) printf "%-14s %s" "Hermes" "(~/.hermes/plugins)" ;; esac } @@ -925,6 +930,133 @@ install_codex() { ok "Codex: $count agents -> $dest" } +hermes_home_dir() { + printf '%s\n' "${HERMES_HOME:-${HOME}/.hermes}" +} + +ensure_hermes_plugin_enabled() { + local hermes_home config plugin backup + hermes_home="$(hermes_home_dir)" + config="${hermes_home}/config.yaml" + plugin="agency-agents-router" + mkdir -p "$hermes_home" + backup="${config}.bak.agency-agents-plugin.$$" + [[ -f "$config" ]] && cp "$config" "$backup" + python3 - "$config" "$plugin" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +plugin = sys.argv[2] +text = path.read_text() if path.exists() else "" +lines = text.splitlines() + +# Already enabled? +in_plugins = False +in_enabled = False +for line in lines: + if line.startswith("plugins:"): + in_plugins = True + in_enabled = False + continue + if in_plugins and line and not line.startswith((" ", "\t")): + in_plugins = False + in_enabled = False + stripped_line = line.strip() + if in_plugins and stripped_line == "enabled:": + in_enabled = True + continue + if in_plugins and stripped_line.startswith("enabled:") and "[]" in stripped_line: + in_enabled = False + continue + if in_enabled: + stripped = line.strip() + if stripped.startswith("-"): + value = stripped[1:].strip().strip('"\'') + if value == plugin: + sys.exit(0) + elif line.startswith(" ") and stripped.endswith(":"): + in_enabled = False + +if not lines: + lines = ["plugins:", " enabled:", f" - {plugin}"] +elif not any(line.startswith("plugins:") for line in lines): + if lines and lines[-1].strip(): + lines.append("") + lines.extend(["plugins:", " enabled:", f" - {plugin}"]) +else: + out = [] + in_plugins = False + inserted = False + saw_enabled = False + for idx, line in enumerate(lines): + if line.startswith("plugins:"): + in_plugins = True + out.append(line) + continue + if in_plugins and line and not line.startswith((" ", "\t")): + if not saw_enabled and not inserted: + out.extend([" enabled:", f" - {plugin}"]) + inserted = True + in_plugins = False + out.append(line) + continue + if in_plugins and line.strip().startswith("enabled:") and "[]" in line: + saw_enabled = True + out.extend([" enabled:", f" - {plugin}"]) + inserted = True + continue + if in_plugins and line.strip() == "enabled:": + saw_enabled = True + out.append(line) + # Insert before the next sibling key or top-level key; if the list is + # empty this still creates a valid block. + out.append(f" - {plugin}") + inserted = True + continue + out.append(line) + if in_plugins and not saw_enabled and not inserted: + out.extend([" enabled:", f" - {plugin}"]) + lines = out +path.write_text("\n".join(lines) + "\n") +PY + if [[ -f "$backup" ]]; then + ok "Hermes: enabled plugin $plugin in $config (backup: $backup)" + else + ok "Hermes: created config.yaml with plugins.enabled: $plugin" + fi +} + +install_hermes() { + local src="$INTEGRATIONS/hermes/agency-agents-router" + local hermes_home; hermes_home="$(hermes_home_dir)" + local dest; dest="$(resolve_dest hermes "${hermes_home}/plugins/agency-agents-router")" + [[ -f "$src/plugin.yaml" && -f "$src/__init__.py" && -f "$src/data/agents.json" ]] || { + err "integrations/hermes/agency-agents-router missing. Run ./scripts/convert.sh --tool hermes first." + return 1 + } + mkdir -p "$(dirname "$dest")" + rm -rf "$dest" + if $USE_LINK; then + ln -s "$src" "$dest" + else + cp -R "$src" "$dest" + fi + ensure_hermes_plugin_enabled || warn "Hermes: plugin installed but config.yaml was not updated." + local count + count="$(python3 - "$src/data/agents.json" <<'PY' +from pathlib import Path +import json, sys +print(len(json.loads(Path(sys.argv[1]).read_text()))) +PY +)" + ok "Hermes: lazy-router plugin ($count agents on disk) -> $dest" + warn "Hermes: restart sessions/gateway so the new plugin toolset is discovered." + if $SELECTION_ACTIVE; then + warn "Hermes: selection flags ignored; router keeps the full roster on disk and loads agents lazily." + fi +} + install_tool() { ensure_converted "$1" case "$1" in @@ -941,6 +1073,7 @@ install_tool() { kimi) install_kimi ;; codex) install_codex ;; osaurus) install_osaurus ;; + hermes) install_hermes ;; esac } diff --git a/tools.json b/tools.json index 4bbd9c31..671344cd 100644 --- a/tools.json +++ b/tools.json @@ -13,6 +13,7 @@ "antigravity": {"id":"antigravity","label":"Antigravity","short":"antigravity","kebab":"antigravity","accent":"#0EA5E9","icon":"antigravity","order":10,"scope":{"user":true,"project":false},"detect":{"dirs":[".gemini/antigravity/skills"],"agentsDir":".gemini/antigravity/skills"},"version":{"bin":"agy","args":["--version"]},"format":"antigravity-skill","slugFrom":"name","slugPrefix":"agency-","dest":{"user":[".gemini/antigravity/skills/{slug}/SKILL.md"],"project":[]}}, "kimi": {"id":"kimi","label":"Kimi","short":"Kimi","kebab":"kimi","accent":"#0F0F12","icon":"kimi","order":11,"scope":{"user":true,"project":false},"detect":{"dirs":[],"agentsDir":".config/kimi/agents"},"version":{"bin":"kimi","args":["--version"]},"format":"kimi-agent","slugFrom":"name","dest":{"user":[".config/kimi/agents/{slug}/agent.yaml",".config/kimi/agents/{slug}/system.md"],"project":[]}}, "openclaw": {"id":"openclaw","label":"OpenClaw","short":"openclaw","kebab":"openclaw","accent":"#E11D48","icon":null,"order":12,"scope":{"user":true,"project":false},"detect":{"dirs":[".openclaw"],"agentsDir":".openclaw/agency-agents"},"version":{"bin":"openclaw","args":["--version"]},"format":"openclaw-workspace","slugFrom":"name","dest":{"user":[".openclaw/agency-agents/{slug}/SOUL.md",".openclaw/agency-agents/{slug}/AGENTS.md",".openclaw/agency-agents/{slug}/IDENTITY.md"],"project":[]}}, - "windsurf": {"id":"windsurf","label":"Windsurf","short":"Windsurf","kebab":"windsurf","accent":"#09B6A2","icon":"windsurf","order":13,"scope":{"user":false,"project":true},"detect":{"dirs":[".codeium"],"agentsDir":null},"version":{"bin":"windsurf","args":["--version"]},"format":"windsurf-rules","slugFrom":null,"dest":{"user":[],"project":[".windsurfrules"]}} + "windsurf": {"id":"windsurf","label":"Windsurf","short":"Windsurf","kebab":"windsurf","accent":"#09B6A2","icon":"windsurf","order":13,"scope":{"user":false,"project":true},"detect":{"dirs":[".codeium"],"agentsDir":null},"version":{"bin":"windsurf","args":["--version"]},"format":"windsurf-rules","slugFrom":null,"dest":{"user":[],"project":[".windsurfrules"]}}, + "hermes": {"id":"hermes","label":"Hermes","short":"Hermes","kebab":"hermes","accent":"#7C3AED","icon":null,"order":14,"scope":{"user":true,"project":false},"detect":{"dirs":[".hermes"],"agentsDir":".hermes/plugins"},"version":{"bin":"hermes","args":["--version"]},"format":"hermes-router-plugin","slugFrom":null,"dest":{"user":[".hermes/plugins/agency-agents-router"],"project":[]}} } }