Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add Hermes Agent integration (with review fixes)
- Full SkillsIntegration subclass with dual install strategy
  (project-local .hermes/skills/ + global ~/.hermes/skills/)
- CLI fix: integration_uninstall now calls integration.teardown()
  instead of manifest.uninstall() directly, allowing custom cleanup
- Fix Copilot review issues:
  - Docstring now reflects both -Q (quiet) and -q (query) flags
  - Empty command guard prevents passing empty skill names
- Add catalog entry for hermes in integrations/catalog.json

Co-authored-by: Zhaoxiaoguang001 <3357983213@qq.com>
  • Loading branch information
majordave and Zhaoxiaoguang001 committed May 20, 2026
commit 541b278ccf9087592202018b79a1244be259fa17
9 changes: 9 additions & 0 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,15 @@
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"hermes": {
"id": "hermes",
"name": "Hermes Agent",
"version": "1.0.0",
"description": "Hermes Agent skills-based integration by Nous Research",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
}
}
}
8 changes: 4 additions & 4 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1979,11 +1979,11 @@ def integration_uninstall(
console.print(f"[dim]Details:[/dim] {exc}")
raise typer.Exit(1)

removed, skipped = manifest.uninstall(project_root, force=force)
if not integration:
console.print(f"[red]Error:[/red] Integration '{key}' not found in registry.")
raise typer.Exit(1)

# Remove managed context section from the agent context file
if integration:
integration.remove_context_section(project_root)
removed, skipped = integration.teardown(project_root, manifest, force=force)
Comment thread
mnriem marked this conversation as resolved.
Outdated

Comment thread
mnriem marked this conversation as resolved.
remaining = [installed for installed in installed_keys if installed != key]
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
Expand Down
123 changes: 114 additions & 9 deletions src/specify_cli/integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Hermes Agent integration — skills-based agent.

Hermes Agent (https://github.com/NousResearch/hermes-agent) is an open-source
AI agent framework by Nous Research. It uses the ``.hermes/skills/`` directory
for agent skills, following the same ``speckit-<name>/SKILL.md`` layout as
Claude Code and Codex.
AI agent framework by Nous Research. It stores skills in
``~/.hermes/skills/`` (user-global) rather than a project-local directory.

Usage::

Expand All @@ -13,11 +12,22 @@

from __future__ import annotations

from pathlib import Path
from shutil import rmtree
from typing import Any

from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest


class HermesIntegration(SkillsIntegration):
"""Integration for Hermes Agent skills."""
"""Integration for Hermes Agent skills.

Hermes loads skills from ``~/.hermes/skills/`` (user home directory)
rather than a project-local path. Skills are installed in both
locations so they are available to Hermes globally while still being
tracked in the project manifest for clean uninstall.
"""

key = "hermes"
config = {
Expand All @@ -35,6 +45,15 @@ class HermesIntegration(SkillsIntegration):
}
Comment thread
mnriem marked this conversation as resolved.
context_file = "AGENTS.md"

# -- Helpers -----------------------------------------------------------

@staticmethod
def _hermes_home_skills_dir() -> Path:
"""Return ``~/.hermes/skills/`` — the global skills directory."""
return Path.home() / ".hermes" / "skills"

# -- Options -----------------------------------------------------------

@classmethod
def options(cls) -> list[IntegrationOption]:
return [
Expand All @@ -46,6 +65,88 @@ def options(cls) -> list[IntegrationOption]:
),
]

# -- Skills directory --------------------------------------------------

def skills_dest(self, project_root: Path) -> Path:
"""Return the project-local skills directory."""
return project_root / ".hermes" / "skills"

# -- Setup -------------------------------------------------------------

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install command templates as Hermes skills.

Delegates to ``super().setup()`` for the project-local
``.hermes/skills/`` (tracked by the manifest for clean uninstall),
then also writes each skill to the global ``~/.hermes/skills/``
where Hermes discovers them at runtime.
"""
# Let the parent class handle project-local installation
created = super().setup(
project_root, manifest,
parsed_options=parsed_options,
**opts,
)

# Also write each skill to the global Hermes skills directory
global_skills_dir = self._hermes_home_skills_dir()
global_skills_dir.mkdir(parents=True, exist_ok=True)

Comment thread
mnriem marked this conversation as resolved.
for skill_md in created:
# Only copy SKILL.md files under the project skills directory
try:
skill_md.resolve().relative_to(
self.skills_dest(project_root).resolve()
)
except ValueError:
continue
if skill_md.name != "SKILL.md":
continue

skill_name = skill_md.parent.name # e.g. "speckit-plan"
global_skill_dir = global_skills_dir / skill_name
global_skill_dir.mkdir(parents=True, exist_ok=True)
global_dest = global_skill_dir / "SKILL.md"

content = skill_md.read_bytes()
normalized = content.replace(b"\r\n", b"\n")
global_dest.write_bytes(normalized)

return created

# -- Uninstall ---------------------------------------------------------

def teardown(
self,
project_root: Path,
manifest: IntegrationManifest,
*,
force: bool = False,
) -> tuple[list[Path], list[Path]]:
Comment thread
mnriem marked this conversation as resolved.
"""Uninstall integration files and clean up global skills."""
# Remove managed context section from AGENTS.md
self.remove_context_section(project_root)

# Remove project-local files via manifest
removed, skipped = manifest.uninstall(project_root, force=force)

# Also remove global Hermes skills for speckit
global_skills_dir = self._hermes_home_skills_dir()
if global_skills_dir.is_dir():
for skill_dir in global_skills_dir.iterdir():
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
rmtree(skill_dir, ignore_errors=True)

return removed, skipped

# -- CLI dispatch ------------------------------------------------------

def build_exec_args(
self,
prompt: str,
Expand All @@ -55,8 +156,9 @@ def build_exec_args(
) -> list[str] | None:
"""Build Hermes CLI invocation for programmatic dispatch.

Uses ``hermes chat -q`` for one-shot queries, mapping slash-command
invocations to the appropriate skill-based dispatch.
Uses ``hermes chat -Q -q`` for one-shot queries in quiet mode,
mapping slash-command invocations to the appropriate skill-based
dispatch.
"""
args = [self.key, "chat", "-Q"]

Expand All @@ -69,9 +171,12 @@ def build_exec_args(
# so Hermes can dispatch to the appropriate skill.
if prompt.startswith("/"):
command, _, remainder = prompt[1:].partition(" ")
args.extend(["-s", command])
if remainder:
args.extend(["-q", remainder])
if command:
args.extend(["-s", command])
if remainder:
args.extend(["-q", remainder])
else:
args.extend(["-q", prompt])
else:
args.extend(["-q", prompt])

Expand Down