Skip to content

Commit 5a50b75

Browse files
feat: add Hermes Agent integration (with review fixes) (#2651)
* feat: add Hermes Agent integration * feat: add Hermes Agent integration * feat: add Hermes Agent integration * 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> * feat: write Hermes skills directly to global ~/.hermes/skills/ Hermes loads skills from the global ~/.hermes/skills/ directory, not from project-local paths. The old dual-install strategy copied SKILL.md files to both locations — project-local (for manifest tracking) and global (for Hermes discovery). This change removes the project-local copies entirely: - setup() writes directly to ~/.hermes/skills/speckit-*/SKILL.md - An empty .hermes/skills/ marker directory is created in the project so extension commands (e.g. git) can detect Hermes as an active integration via register_commands_for_all_agents() - teardown() cleans both the global speckit-* dirs and the local marker - import yaml moved to local import inside setup() Tests updated: Hermes-specific tests now assert global skill location, and shared SkillsIntegrationTests that assumed project-local files are overridden with Hermes-appropriate assertions. Co-authored-by: Zhaoxiaoguang001 <3357983213@qq.com> * fix: address Copilot review feedback on Hermes integration Addresses all 6 review comments from copilot-pull-request-reviewer: 1. Hard-fail on missing integration key → fall back to manifest.uninstall() with a warning instead of raising an error. Allows users to always remove stale integration files even when the integration class is missing from the registry. 2. HOME isolation in tests → every test that calls setup() or CliRunner now monkeypatches Path.home() to a temp directory, keeping the test suite hermetic and non-destructive. 3. HermesIntegration.teardown() now delegates to manifest.uninstall() for project-local tracked files (scripts, manifest), merging results with global cleanup. 4. Global skills cleanup gated behind force=True to avoid destroying speckit-* skills shared across multiple Spec Kit projects when running 'specify integration uninstall hermes' without --force. 5. Line 160 isolation (CLI test test_complete_file_inventory_sh). 6. Line 258 isolation (Path.home assertion in test_ai_hermes_without_ai_skills_auto_promotes). * fix: address second Copilot review round — 6 remaining observations - Move to module scope (was inside per-template loop) - Add safety checks in setup() matching standard - Fix docstrings: global skills always removed on uninstall (standard) - Fix removal tracking: only report after successful rmtree - Override shared test_modified_file_survives_uninstall with Hermes-appropriate behaviour (global skills always removed, no hash tracking) - Update PR description to match implementation (global-only skills + marker) * fix: add first-class global/home-based agent dir support in CommandRegistrar Resolves Copilot HIGH concern (discussion_r3312194525): HermesIntegration.registrar_config.dir was '.hermes/skills' (project- relative), but skills live in ~/.hermes/skills/ (global). Extensions and presets registering commands for the 'hermes' agent via CommandRegistrar would write to the project-local marker directory instead of the real global skills directory, making those commands invisible to Hermes. Fix consists of three parts: 1. CommandRegistrar._resolve_agent_dir now supports '~/'-prefixed and absolute paths in agent_config['dir']. Relative paths still resolve against project_root as before — zero change for existing agents (Claude, Codex, Gemini, etc.). 2. HermesIntegration.registrar_config.dir changed from '.hermes/skills' to '~/.hermes/skills', so extensions/presets write directly to the global directory Hermes searches at runtime. 3. Two inline project_root / agent_config['dir'] calls in the extension update backup/restore paths (src/specify_cli/__init__.py) now delegate to _resolve_agent_dir, giving them the same global-dir support plus the legacy_dir fallback they were missing (improvement for all agents). Test side-effect: test_update_failure_rolls_back_registry_hooks_and_commands was constructing verification paths with project_dir / '~/.hermes/skills' (literal tilde) — fixed to use _resolve_agent_dir and monkeypatch Path.home() so Hermes' global dir doesn't leak into the real filesystem. * fix: address remaining 3 Copilot review observations (round 3) - teardown docstring: clarify marker removal is conditional (if empty) - test_pre_existing_skills_not_removed: now actually calls teardown() to verify foreign skills survive uninstall (was only running setup) - integration_switch Phase 1: replaced old_manifest.uninstall() + remove_context_section() with current_integration.teardown(), matching the pattern already used in integration_uninstall. This ensures custom teardown logic (e.g. Hermes global skills cleanup) runs during switches. * fix: address Copilot round 4 — home-relative dir resolution + project-local detection 1. _resolve_agent_dir(): expand ~/... via Path.home() + slice instead of expanduser(), so tests that monkeypatch Path.home() properly isolate the home directory (Copilot r3312731595, r3312731729) 2. Add detect_dir field to registrar_config: Hermes declares detect_dir='.hermes/skills' (project-local marker). CommandRegistrar checks detect_dir before resolving the output dir, preventing global dirs like ~/.hermes/skills from causing false detection in every project (Copilot r3312731682) 3. test_update_failure_rolls_back: no additional changes needed — the _resolve_agent_dir fix makes the existing Path.home() monkeypatch effective, so ~/.hermes/skills is not found in the fake home and Hermes is properly skipped. Tests: 2236 passed (2009 integration + 195 extension + 32 hermes) --------- Co-authored-by: Zhaoxiaoguang001 <3357983213@qq.com> Co-authored-by: majordave <majordave@users.noreply.github.com>
1 parent 0a8f31e commit 5a50b75

7 files changed

Lines changed: 678 additions & 16 deletions

File tree

integrations/catalog.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,15 @@
272272
"author": "spec-kit-core",
273273
"repository": "https://github.com/github/spec-kit",
274274
"tags": ["cli"]
275+
},
276+
"hermes": {
277+
"id": "hermes",
278+
"name": "Hermes Agent",
279+
"version": "1.0.0",
280+
"description": "Hermes Agent skills-based integration by Nous Research",
281+
"author": "spec-kit-core",
282+
"repository": "https://github.com/github/spec-kit",
283+
"tags": ["cli", "skills"]
275284
}
276285
}
277286
}

src/specify_cli/__init__.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,11 +1218,14 @@ def integration_uninstall(
12181218
console.print(f"[dim]Details:[/dim] {exc}")
12191219
raise typer.Exit(1)
12201220

1221-
removed, skipped = manifest.uninstall(project_root, force=force)
1222-
1223-
# Remove managed context section from the agent context file
1224-
if integration:
1225-
integration.remove_context_section(project_root)
1221+
if not integration:
1222+
console.print(
1223+
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
1224+
"in registry. Falling back to manifest-based cleanup."
1225+
)
1226+
removed, skipped = manifest.uninstall(project_root, force=force)
1227+
else:
1228+
removed, skipped = integration.teardown(project_root, manifest, force=force)
12261229

12271230
remaining = [installed for installed in installed_keys if installed != key]
12281231
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
@@ -1364,8 +1367,9 @@ def integration_switch(
13641367
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
13651368
)
13661369
raise typer.Exit(1)
1367-
removed, skipped = old_manifest.uninstall(project_root, force=force)
1368-
current_integration.remove_context_section(project_root)
1370+
removed, skipped = current_integration.teardown(
1371+
project_root, old_manifest, force=force,
1372+
)
13691373
if removed:
13701374
console.print(f" Removed {len(removed)} file(s)")
13711375
if skipped:
@@ -3601,7 +3605,9 @@ def extension_update(
36013605
if agent_name not in registrar.AGENT_CONFIGS:
36023606
continue
36033607
agent_config = registrar.AGENT_CONFIGS[agent_name]
3604-
commands_dir = project_root / agent_config["dir"]
3608+
commands_dir = _AgentReg._resolve_agent_dir(
3609+
agent_name, agent_config, project_root
3610+
)
36053611

36063612
for cmd_name in cmd_names:
36073613
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
@@ -3762,7 +3768,9 @@ def extension_update(
37623768
if agent_name not in registrar.AGENT_CONFIGS:
37633769
continue
37643770
agent_config = registrar.AGENT_CONFIGS[agent_name]
3765-
commands_dir = project_root / agent_config["dir"]
3771+
commands_dir = _AgentReg._resolve_agent_dir(
3772+
agent_name, agent_config, project_root
3773+
)
37663774

37673775
for cmd_name in cmd_names:
37683776
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)

src/specify_cli/agents.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -654,15 +654,28 @@ def _resolve_agent_dir(
654654
) -> Path:
655655
"""Return the agent command directory, falling back to legacy_dir.
656656
657-
When the canonical directory (``agent_config["dir"]``) does not
658-
exist but a ``legacy_dir`` is configured and present on disk,
659-
returns the legacy path and emits a deprecation warning advising
660-
the user to upgrade.
657+
Supports project-relative paths (e.g. ``.claude/skills/``),
658+
home-relative paths (e.g. ``~/.hermes/skills``), and absolute
659+
paths — the ``agent_config["dir"]`` value is resolved verbatim
660+
when absolute or starting with ``~/``, or joined with
661+
``project_root`` when relative.
662+
663+
When the canonical directory does not exist but a ``legacy_dir``
664+
is configured and present on disk, returns the legacy path and
665+
emits a deprecation warning advising the user to upgrade.
661666
662667
Integrations that do not declare ``legacy_dir`` get the canonical
663668
path unconditionally — no fallback, no warning.
664669
"""
665-
agent_dir = project_root / agent_config["dir"]
670+
dir_str = agent_config["dir"]
671+
if dir_str.startswith("~"):
672+
# Use Path.home() + remainder instead of expanduser() so tests
673+
# that monkeypatch Path.home() can properly isolate the home dir.
674+
# expanduser() uses OS env/user lookup and ignores monkeypatches.
675+
agent_dir = Path.home() / dir_str[1:].lstrip("/")
676+
else:
677+
p = Path(dir_str)
678+
agent_dir = p if p.is_absolute() else project_root / p
666679
if not agent_dir.exists():
667680
legacy = agent_config.get("legacy_dir")
668681
if legacy:
@@ -704,6 +717,15 @@ def register_commands_for_all_agents(
704717

705718
self._ensure_configs()
706719
for agent_name, agent_config in self.AGENT_CONFIGS.items():
720+
# Check detect_dir first (project-local marker) if configured,
721+
# falling back to the resolved dir for output. This prevents
722+
# global dirs (e.g. ~/.hermes/skills) from causing false
723+
# detection in every project.
724+
detect_dir_str = agent_config.get("detect_dir")
725+
if detect_dir_str:
726+
detect_path = project_root / detect_dir_str
727+
if not detect_path.exists():
728+
continue
707729
agent_dir = self._resolve_agent_dir(
708730
agent_name, agent_config, project_root,
709731
)
@@ -755,6 +777,11 @@ def register_commands_for_non_skill_agents(
755777
for agent_name, agent_config in self.AGENT_CONFIGS.items():
756778
if agent_config.get("extension") == "/SKILL.md":
757779
continue
780+
detect_dir_str = agent_config.get("detect_dir")
781+
if detect_dir_str:
782+
detect_path = project_root / detect_dir_str
783+
if not detect_path.exists():
784+
continue
758785
agent_dir = self._resolve_agent_dir(
759786
agent_name, agent_config, project_root,
760787
)

src/specify_cli/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def _register_builtins() -> None:
6161
from .gemini import GeminiIntegration
6262
from .generic import GenericIntegration
6363
from .goose import GooseIntegration
64+
from .hermes import HermesIntegration
6465
from .iflow import IflowIntegration
6566
from .junie import JunieIntegration
6667
from .kilocode import KilocodeIntegration
@@ -93,6 +94,7 @@ def _register_builtins() -> None:
9394
_register(GeminiIntegration())
9495
_register(GenericIntegration())
9596
_register(GooseIntegration())
97+
_register(HermesIntegration())
9698
_register(IflowIntegration())
9799
_register(JunieIntegration())
98100
_register(KilocodeIntegration())

0 commit comments

Comments
 (0)