mirror of
https://github.com/msitarzewski/agency-agents.git
synced 2026-07-06 16:28:56 +03:00
Add Hermes lazy Agency router plugin (#614)
* Add Hermes lazy agency router plugin * Document Hermes router specialist usage
This commit is contained in:
@@ -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())
|
||||
+10
-3
@@ -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/<name>/SKILL.md)
|
||||
# hermes — Hermes lazy-router plugin (one plugin + on-disk agent index)
|
||||
# all — All tools (default)
|
||||
#
|
||||
# Output is written to integrations/<tool>/ 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)..."
|
||||
|
||||
+136
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user