55``.hermes/skills/`` directory. A project-local marker (empty directory)
66is created so extension commands (e.g. git) can detect Hermes.
77
8- Several shared tests from ``SkillsIntegrationTests`` assume project-local
9- skills — they are overridden here with Hermes-appropriate assertions.
8+ All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate
9+ ``Path.home()`` to a temp directory so the test suite is hermetic and
10+ non-destructive to a developer's real Hermes installation.
1011"""
1112
1213from pathlib import Path
1314
14- from specify_cli .integrations import INTEGRATION_REGISTRY , get_integration
15- from specify_cli .integrations .hermes import HermesIntegration
15+ from specify_cli .integrations import get_integration
1616from specify_cli .integrations .manifest import IntegrationManifest
1717
1818from .test_integration_base_skills import SkillsIntegrationTests
1919
2020
21+ def _fake_home (tmp_path : Path ) -> Path :
22+ """Create and return an isolated home directory under *tmp_path*."""
23+ home = tmp_path / "home"
24+ home .mkdir (exist_ok = True )
25+ return home
26+
27+
2128class TestHermesIntegration (SkillsIntegrationTests ):
2229 KEY = "hermes"
2330 FOLDER = ".hermes/"
@@ -27,8 +34,11 @@ class TestHermesIntegration(SkillsIntegrationTests):
2734
2835 # -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
2936
30- def test_setup_writes_to_global_skills_dir (self , tmp_path ):
37+ def test_setup_writes_to_global_skills_dir (self , tmp_path , monkeypatch ):
3138 """Skills are written to ~/.hermes/skills/, not project-local."""
39+ home = _fake_home (tmp_path )
40+ monkeypatch .setattr (Path , "home" , lambda : home )
41+
3242 i = get_integration (self .KEY )
3343 m = IntegrationManifest (self .KEY , tmp_path )
3444 created = i .setup (tmp_path , m )
@@ -37,12 +47,16 @@ def test_setup_writes_to_global_skills_dir(self, tmp_path):
3747 assert len (skill_files ) > 0 , "No skill files were created"
3848 for f in skill_files :
3949 # Every skill file should be under ~/.hermes/skills/speckit-*/
40- assert str (f ).startswith (
41- str (Path .home () / ".hermes" / "skills" )
42- ), f"{ f } is not under ~/.hermes/skills/"
50+ expected_prefix = str (home / ".hermes" / "skills" )
51+ assert str (f ).startswith (expected_prefix ), (
52+ f"{ f } is not under ~/.hermes/skills/"
53+ )
4354
44- def test_local_marker_dir_created (self , tmp_path ):
55+ def test_local_marker_dir_created (self , tmp_path , monkeypatch ):
4556 """Project-local .hermes/skills/ should exist but be empty."""
57+ home = _fake_home (tmp_path )
58+ monkeypatch .setattr (Path , "home" , lambda : home )
59+
4660 i = get_integration (self .KEY )
4761 m = IntegrationManifest (self .KEY , tmp_path )
4862 i .setup (tmp_path , m )
@@ -54,19 +68,22 @@ def test_local_marker_dir_created(self, tmp_path):
5468
5569 # -- Override shared tests that assume project-local skills ------------
5670
57- def test_setup_writes_to_correct_directory (self , tmp_path ):
71+ def test_setup_writes_to_correct_directory (self , tmp_path , monkeypatch ):
5872 """Override: Hermes writes to global, not project-local."""
59- self .test_setup_writes_to_global_skills_dir (tmp_path )
73+ self .test_setup_writes_to_global_skills_dir (tmp_path , monkeypatch )
6074
61- def test_plan_references_correct_context_file (self , tmp_path ):
75+ def test_plan_references_correct_context_file (self , tmp_path , monkeypatch ):
6276 """Plan skill goes to global dir, but we check it still references AGENTS.md."""
77+ home = _fake_home (tmp_path )
78+ monkeypatch .setattr (Path , "home" , lambda : home )
79+
6380 i = get_integration (self .KEY )
6481 if not i .context_file :
6582 return
6683 m = IntegrationManifest (self .KEY , tmp_path )
67- created = i .setup (tmp_path , m )
84+ i .setup (tmp_path , m )
6885 # Find the plan skill in global ~/.hermes/skills/
69- plan_file = Path . home () / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
86+ plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
7087 assert plan_file .exists (), f"Plan skill { plan_file } not created globally"
7188 content = plan_file .read_text (encoding = "utf-8" )
7289 assert i .context_file in content , (
@@ -76,22 +93,28 @@ def test_plan_references_correct_context_file(self, tmp_path):
7693 "Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
7794 )
7895
79- def test_all_files_tracked_in_manifest (self , tmp_path ):
96+ def test_all_files_tracked_in_manifest (self , tmp_path , monkeypatch ):
8097 """Override: Hermes does not track skills in the project manifest
8198 since they live globally. Only project-local files (scripts,
8299 templates, context) are tracked."""
100+ home = _fake_home (tmp_path )
101+ monkeypatch .setattr (Path , "home" , lambda : home )
102+
83103 i = get_integration (self .KEY )
84104 m = IntegrationManifest (self .KEY , tmp_path )
85105 created = i .setup (tmp_path , m )
86106 for f in created :
87107 # Global files (in ~/.hermes/) are not tracked in manifest
88- if str (f ).startswith (str (Path . home () )):
108+ if str (f ).startswith (str (home )):
89109 continue
90110 rel = f .resolve ().relative_to (tmp_path .resolve ()).as_posix ()
91111 assert rel in m .files , f"{ rel } not tracked in manifest"
92112
93- def test_install_uninstall_roundtrip (self , tmp_path ):
113+ def test_install_uninstall_roundtrip (self , tmp_path , monkeypatch ):
94114 """Override: Hermes uninstall removes global skills + local marker."""
115+ home = _fake_home (tmp_path )
116+ monkeypatch .setattr (Path , "home" , lambda : home )
117+
95118 i = get_integration (self .KEY )
96119 m = IntegrationManifest (self .KEY , tmp_path )
97120 created = i .install (tmp_path , m )
@@ -101,17 +124,19 @@ def test_install_uninstall_roundtrip(self, tmp_path):
101124 for f in created :
102125 if "SKILL.md" in str (f ):
103126 assert f .exists (), f"{ f } does not exist"
104- removed , skipped = i .uninstall (tmp_path , m )
105- # Global skills should be gone
127+ removed , skipped = i .teardown (tmp_path , m , force = True )
106128 for f in created :
107129 if "SKILL.md" in str (f ):
108130 assert not f .exists (), f"{ f } should have been removed"
109131 # Local marker should be gone
110132 assert not (tmp_path / ".hermes" / "skills" ).exists ()
111133
112- def test_modified_file_survives_uninstall (self , tmp_path ):
113- """Override: Hermes global skills are always removed on uninstall
114- (no hash-based preservation since they're not in manifest)."""
134+ def test_modified_file_survives_uninstall (self , tmp_path , monkeypatch ):
135+ """Override: Hermes global skills are removed on uninstall only
136+ when force=True (no hash-based preservation since they're not in manifest)."""
137+ home = _fake_home (tmp_path )
138+ monkeypatch .setattr (Path , "home" , lambda : home )
139+
115140 i = get_integration (self .KEY )
116141 m = IntegrationManifest (self .KEY , tmp_path )
117142 created = i .install (tmp_path , m )
@@ -121,15 +146,18 @@ def test_modified_file_survives_uninstall(self, tmp_path):
121146 assert len (skill_files ) > 0
122147 modified_file = skill_files [0 ]
123148 modified_file .write_text ("user modified this" , encoding = "utf-8" )
124- removed , skipped = i . uninstall ( tmp_path , m )
125- # Global skills are removed unconditionally (no manifest tracking )
149+ # Global skills are only removed with force=True
150+ removed , skipped = i . teardown ( tmp_path , m , force = True )
126151 assert not modified_file .exists (), (
127- "Modified global skill should be removed on uninstall "
152+ "Modified global skill should be removed on teardown with force=True "
128153 )
129154
130- def test_pre_existing_skills_not_removed (self , tmp_path ):
155+ def test_pre_existing_skills_not_removed (self , tmp_path , monkeypatch ):
131156 """Override: pre-existing non-speckit skills in the global dir
132157 should survive Hermes uninstall."""
158+ home = _fake_home (tmp_path )
159+ monkeypatch .setattr (Path , "home" , lambda : home )
160+
133161 i = get_integration (self .KEY )
134162 # Create a foreign skill in the global dir first
135163 global_skills_dir = i ._hermes_home_skills_dir ()
@@ -142,9 +170,12 @@ def test_pre_existing_skills_not_removed(self, tmp_path):
142170
143171 assert (foreign_dir / "SKILL.md" ).exists (), "Foreign skill was removed"
144172
145- def test_complete_file_inventory_sh (self , tmp_path ):
173+ def test_complete_file_inventory_sh (self , tmp_path , monkeypatch ):
146174 """Override: Hermes init produces no local SKILL.md files,
147175 only the empty .hermes/skills/ marker."""
176+ home = _fake_home (tmp_path )
177+ monkeypatch .setattr (Path , "home" , lambda : home )
178+
148179 from typer .testing import CliRunner
149180 from specify_cli import app
150181
@@ -173,8 +204,11 @@ def test_complete_file_inventory_sh(self, tmp_path):
173204 # Ensure the marker exists (empty dir won't appear in file listing)
174205 assert (project / ".hermes" / "skills" ).is_dir ()
175206
176- def test_complete_file_inventory_ps (self , tmp_path ):
207+ def test_complete_file_inventory_ps (self , tmp_path , monkeypatch ):
177208 """Override: Same as sh variant but for PowerShell script type."""
209+ home = _fake_home (tmp_path )
210+ monkeypatch .setattr (Path , "home" , lambda : home )
211+
178212 from typer .testing import CliRunner
179213 from specify_cli import app
180214
@@ -201,8 +235,11 @@ def test_complete_file_inventory_ps(self, tmp_path):
201235 )
202236 assert (project / ".hermes" / "skills" ).is_dir ()
203237
204- def test_install_uninstall_cleanup (self , tmp_path ):
238+ def test_install_uninstall_cleanup (self , tmp_path , monkeypatch ):
205239 """Verify global skills are cleaned and local marker is removed."""
240+ home = _fake_home (tmp_path )
241+ monkeypatch .setattr (Path , "home" , lambda : home )
242+
206243 i = get_integration (self .KEY )
207244 m = IntegrationManifest (self .KEY , tmp_path )
208245 created = i .setup (tmp_path , m )
@@ -211,7 +248,7 @@ def test_install_uninstall_cleanup(self, tmp_path):
211248 global_skills = [
212249 f for f in created
213250 if "SKILL.md" in str (f )
214- and str (f ).startswith (str (Path . home () / ".hermes" ))
251+ and str (f ).startswith (str (home / ".hermes" ))
215252 ]
216253 assert len (global_skills ) > 0
217254 for f in global_skills :
@@ -220,8 +257,8 @@ def test_install_uninstall_cleanup(self, tmp_path):
220257 # Verify local marker exists
221258 assert (tmp_path / ".hermes" / "skills" ).is_dir ()
222259
223- # Teardown
224- removed , skipped = i .teardown (tmp_path , m )
260+ # Teardown with force=True to clean global skills
261+ removed , skipped = i .teardown (tmp_path , m , force = True )
225262
226263 # Global skills removed
227264 for f in global_skills :
@@ -236,9 +273,12 @@ def test_install_uninstall_cleanup(self, tmp_path):
236273class TestHermesAutoPromote :
237274 """--ai hermes auto-promotes to integration path."""
238275
239- def test_ai_hermes_without_ai_skills_auto_promotes (self , tmp_path ):
276+ def test_ai_hermes_without_ai_skills_auto_promotes (self , tmp_path , monkeypatch ):
240277 """--ai hermes should work the same as --integration hermes,
241278 creating global skills and a local marker."""
279+ home = _fake_home (tmp_path )
280+ monkeypatch .setattr (Path , "home" , lambda : home )
281+
242282 from typer .testing import CliRunner
243283 from specify_cli import app
244284
@@ -254,7 +294,7 @@ def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path):
254294
255295 assert result .exit_code == 0 , f"init --ai hermes failed: { result .output } "
256296 # Skills should be in global ~/.hermes/skills/
257- assert (Path . home () / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" ).exists ()
297+ assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" ).exists ()
258298 # Local marker should exist
259299 assert (target / ".hermes" / "skills" ).is_dir ()
260300 # No SKILL.md files in project-local dir
0 commit comments