Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
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
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
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def _register_builtins() -> None:
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .goose import GooseIntegration
from .hermes import HermesIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
Expand Down Expand Up @@ -93,6 +94,7 @@ def _register_builtins() -> None:
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(HermesIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())
Expand Down
257 changes: 257 additions & 0 deletions src/specify_cli/integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""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 stores skills in
``~/.hermes/skills/`` (user-global) rather than a project-local directory.

Usage::

specify init my-project --integration hermes
specify init --here --ai hermes
"""

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.

Hermes loads skills from ``~/.hermes/skills/`` (user home directory)
rather than a project-local path. Skills are installed directly to
the global directory — no project-local copies are created since
Hermes discovers them globally. A project-local marker directory
(``.hermes/skills/`` empty) is created so extension commands (e.g.
git) can detect Hermes as an active integration. Uninstall removes
both the marker and global skills.
Comment thread
mnriem marked this conversation as resolved.
Outdated
"""

key = "hermes"
config = {
"name": "Hermes Agent",
"folder": ".hermes/",
"commands_subdir": "skills",
"install_url": "https://github.com/NousResearch/hermes-agent",
"requires_cli": True,
}
registrar_config = {
"dir": ".hermes/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
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 [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Hermes Agent)",
),
]

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

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

Writes each skill directly to
``~/.hermes/skills/speckit-<name>/SKILL.md`` where Hermes
discovers them at runtime. No project-local SKILL.md copies are
created — the global directory is the single source of truth.
A project-local marker (``.hermes/skills/`` empty) is created
so extension commands (e.g. git) can detect Hermes as an active
integration.
Comment thread
mnriem marked this conversation as resolved.
"""
templates = self.list_command_templates()
if not templates:
return []

script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)

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.
created: list[Path] = []

for src_file in templates:
raw = src_file.read_text(encoding="utf-8")

# Derive the skill name from the template stem
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"

# Parse frontmatter for description
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
import yaml

try:
fm = yaml.safe_load(parts[1])
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
pass
Comment thread
mnriem marked this conversation as resolved.

# Process body through the standard template pipeline
processed_body = self.process_template(
raw,
self.key,
script_type,
arg_placeholder,
context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]

# Select description
description = frontmatter.get("description", "")
if not description:
description = f"Spec Kit: {command_name} workflow"

# Build SKILL.md with manually formatted frontmatter
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'

skill_content = (
f"---\n"
f"name: {_quote(skill_name)}\n"
f"description: {_quote(description)}\n"
f"compatibility: "
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n"
f" author: {_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n"
f"{processed_body}"
)

# Write directly to global ~/.hermes/skills/speckit-<name>/SKILL.md
skill_dir = global_skills_dir / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / "SKILL.md"
normalized = skill_content.replace("\r\n", "\n")
skill_file.write_bytes(normalized.encode("utf-8"))
created.append(skill_file)

# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)

# Create project-local marker directory so extension commands
# (e.g. git) can detect Hermes as an active integration.
# Hermes itself ignores this directory — skills live globally.
(project_root / ".hermes" / "skills").mkdir(parents=True, exist_ok=True)
Comment thread
mnriem marked this conversation as resolved.

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.

Removes the managed context section from AGENTS.md, removes the
project-local marker directory, and deletes all ``speckit-*``
directories from ``~/.hermes/skills/``.
"""
# Remove managed context section from AGENTS.md
self.remove_context_section(project_root)

# Remove project-local marker directory if empty
local_skills_dir = project_root / ".hermes" / "skills"
if local_skills_dir.is_dir() and not any(local_skills_dir.iterdir()):
local_skills_dir.rmdir()
hermes_dir = project_root / ".hermes"
if hermes_dir.is_dir() and not any(hermes_dir.iterdir()):
hermes_dir.rmdir()

Comment thread
mnriem marked this conversation as resolved.
# Remove global Hermes skills for speckit
removed: list[Path] = []
global_skills_dir = self._hermes_home_skills_dir()
if global_skills_dir.is_dir():
for skill_dir in sorted(global_skills_dir.iterdir()):
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
removed.append(skill_file)
Comment thread
mnriem marked this conversation as resolved.
Outdated
rmtree(skill_dir, ignore_errors=True)

return removed, []

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

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build Hermes CLI invocation for programmatic 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"]

if model:
args.extend(["-m", model])
if output_json:
args.append("--json")

# If prompt starts with a slash command, pass it directly
# so Hermes can dispatch to the appropriate skill.
if prompt.startswith("/"):
command, _, remainder = prompt[1:].partition(" ")
if command:
args.extend(["-s", command])
if remainder:
args.extend(["-q", remainder])
else:
args.extend(["-q", prompt])
else:
args.extend(["-q", prompt])

return args
Loading
Loading