diff --git a/integrations/catalog.json b/integrations/catalog.json index 16e321cf58..e10f594c6c 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -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"] } } } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d4e8632215..b57b5bffe6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1979,11 +1979,14 @@ def integration_uninstall( console.print(f"[dim]Details:[/dim] {exc}") raise typer.Exit(1) - removed, skipped = manifest.uninstall(project_root, force=force) - - # Remove managed context section from the agent context file - if integration: - integration.remove_context_section(project_root) + if not integration: + console.print( + f"[yellow]Warning:[/yellow] Integration '{key}' not found " + "in registry. Falling back to manifest-based cleanup." + ) + removed, skipped = manifest.uninstall(project_root, force=force) + else: + removed, skipped = integration.teardown(project_root, manifest, force=force) 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) diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 4a78e7d035..ad1440d074 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -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 @@ -93,6 +94,7 @@ def _register_builtins() -> None: _register(GeminiIntegration()) _register(GenericIntegration()) _register(GooseIntegration()) + _register(HermesIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py new file mode 100644 index 0000000000..6d442bae7c --- /dev/null +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -0,0 +1,266 @@ +"""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. + """ + + 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", + } + 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-/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. + """ + 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) + + 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 + + # 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-/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) + + return created + + # -- Uninstall --------------------------------------------------------- + + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall integration files and optionally clean up global skills. + + Removes the managed context section from AGENTS.md, removes the + project-local marker directory, and delegates to + ``manifest.uninstall()`` for project-local tracked files. + + Global ``speckit-*`` skills under ``~/.hermes/skills/`` are only + removed when ``force=True`` to avoid destroying skills shared with + other Spec Kit projects. + """ + # Remove managed context section from AGENTS.md + self.remove_context_section(project_root) + + # Delegate to manifest for project-local tracked files (scripts, + # templates, context entries tracked in the manifest). + removed, skipped = manifest.uninstall(project_root, force=force) + + # 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() + + # Remove global Hermes skills for speckit — only when force=True + # to avoid destroying skills shared with other Spec Kit projects. + if force: + 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) + rmtree(skill_dir, ignore_errors=True) + + return removed, skipped + + # -- 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 diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py new file mode 100644 index 0000000000..854dcfc711 --- /dev/null +++ b/tests/integrations/test_integration_hermes.py @@ -0,0 +1,302 @@ +"""Tests for HermesIntegration. + +Hermes is special among SkillsIntegration subclasses: it writes skills +to ``~/.hermes/skills/`` (global) rather than the project-local +``.hermes/skills/`` directory. A project-local marker (empty directory) +is created so extension commands (e.g. git) can detect Hermes. + +All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate +``Path.home()`` to a temp directory so the test suite is hermetic and +non-destructive to a developer's real Hermes installation. +""" + +from pathlib import Path + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +def _fake_home(tmp_path: Path) -> Path: + """Create and return an isolated home directory under *tmp_path*.""" + home = tmp_path / "home" + home.mkdir(exist_ok=True) + return home + + +class TestHermesIntegration(SkillsIntegrationTests): + KEY = "hermes" + FOLDER = ".hermes/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".hermes/skills" + CONTEXT_FILE = "AGENTS.md" + + # -- Hermes-specific setup: skills go to ~/.hermes/skills/ ------------- + + def test_setup_writes_to_global_skills_dir(self, tmp_path, monkeypatch): + """Skills are written to ~/.hermes/skills/, not project-local.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + assert len(skill_files) > 0, "No skill files were created" + for f in skill_files: + # Every skill file should be under ~/.hermes/skills/speckit-*/ + expected_prefix = str(home / ".hermes" / "skills") + assert str(f).startswith(expected_prefix), ( + f"{f} is not under ~/.hermes/skills/" + ) + + def test_local_marker_dir_created(self, tmp_path, monkeypatch): + """Project-local .hermes/skills/ should exist but be empty.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + marker = tmp_path / ".hermes" / "skills" + assert marker.is_dir(), "Marker directory was not created" + # Should be empty (no SKILL.md files) + children = list(marker.iterdir()) + assert children == [], f"Marker directory should be empty, got: {children}" + + # -- Override shared tests that assume project-local skills ------------ + + def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): + """Override: Hermes writes to global, not project-local.""" + self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) + + def test_plan_references_correct_context_file(self, tmp_path, monkeypatch): + """Plan skill goes to global dir, but we check it still references AGENTS.md.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + # Find the plan skill in global ~/.hermes/skills/ + plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists(), f"Plan skill {plan_file} not created globally" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan skill should reference {i.context_file!r} but it was not found" + ) + assert "__CONTEXT_FILE__" not in content, ( + "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path, monkeypatch): + """Override: Hermes does not track skills in the project manifest + since they live globally. Only project-local files (scripts, + templates, context) are tracked.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + # Global files (in ~/.hermes/) are not tracked in manifest + if str(f).startswith(str(home)): + continue + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path, monkeypatch): + """Override: Hermes uninstall removes global skills + local marker.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + # All SKILL.md files should exist globally + for f in created: + if "SKILL.md" in str(f): + assert f.exists(), f"{f} does not exist" + removed, skipped = i.teardown(tmp_path, m, force=True) + for f in created: + if "SKILL.md" in str(f): + assert not f.exists(), f"{f} should have been removed" + # Local marker should be gone + assert not (tmp_path / ".hermes" / "skills").exists() + + def test_modified_file_survives_uninstall(self, tmp_path, monkeypatch): + """Override: Hermes global skills are removed on uninstall only + when force=True (no hash-based preservation since they're not in manifest).""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + # Pick a global skill file + skill_files = [f for f in created if "SKILL.md" in str(f)] + assert len(skill_files) > 0 + modified_file = skill_files[0] + modified_file.write_text("user modified this", encoding="utf-8") + # Global skills are only removed with force=True + removed, skipped = i.teardown(tmp_path, m, force=True) + assert not modified_file.exists(), ( + "Modified global skill should be removed on teardown with force=True" + ) + + def test_pre_existing_skills_not_removed(self, tmp_path, monkeypatch): + """Override: pre-existing non-speckit skills in the global dir + should survive Hermes uninstall.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + # Create a foreign skill in the global dir first + global_skills_dir = i._hermes_home_skills_dir() + foreign_dir = global_skills_dir / "other-tool" + foreign_dir.mkdir(parents=True, exist_ok=True) + (foreign_dir / "SKILL.md").write_text("# Foreign skill\n") + + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + + assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" + + def test_complete_file_inventory_sh(self, tmp_path, monkeypatch): + """Override: Hermes init produces no local SKILL.md files, + only the empty .hermes/skills/ marker.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = Path.cwd() + import os + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + # Ensure no .hermes/skills/speckit-*/SKILL.md in project dir + hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")] + assert hermes_skill_files == [], ( + f"Expected no local SKILL.md files, found: {hermes_skill_files}" + ) + # Ensure the marker exists (empty dir won't appear in file listing) + assert (project / ".hermes" / "skills").is_dir() + + def test_complete_file_inventory_ps(self, tmp_path, monkeypatch): + """Override: Same as sh variant but for PowerShell script type.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = Path.cwd() + import os + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "ps", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")] + assert hermes_skill_files == [], ( + f"Expected no local SKILL.md files, found: {hermes_skill_files}" + ) + assert (project / ".hermes" / "skills").is_dir() + + def test_install_uninstall_cleanup(self, tmp_path, monkeypatch): + """Verify global skills are cleaned and local marker is removed.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + + # Verify global skills exist + global_skills = [ + f for f in created + if "SKILL.md" in str(f) + and str(f).startswith(str(home / ".hermes")) + ] + assert len(global_skills) > 0 + for f in global_skills: + assert f.exists() + + # Verify local marker exists + assert (tmp_path / ".hermes" / "skills").is_dir() + + # Teardown with force=True to clean global skills + removed, skipped = i.teardown(tmp_path, m, force=True) + + # Global skills removed + for f in global_skills: + assert not f.exists(), f"{f} should have been removed" + + # Local marker removed + assert not (tmp_path / ".hermes" / "skills").exists(), ( + "Local marker should be removed on teardown" + ) + + +class TestHermesAutoPromote: + """--ai hermes auto-promotes to integration path.""" + + def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch): + """--ai hermes should work the same as --integration hermes, + creating global skills and a local marker.""" + home = _fake_home(tmp_path) + monkeypatch.setattr(Path, "home", lambda: home) + + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke(app, [ + "init", str(target), + "--ai", "hermes", + "--no-git", + "--ignore-agent-tools", + "--script", "sh", + ]) + + assert result.exit_code == 0, f"init --ai hermes failed: {result.output}" + # Skills should be in global ~/.hermes/skills/ + assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists() + # Local marker should exist + assert (target / ".hermes" / "skills").is_dir() + # No SKILL.md files in project-local dir + local_skills = list((target / ".hermes" / "skills").iterdir()) + assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"