Skip to content

Commit 70f5732

Browse files
committed
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).
1 parent f19a57e commit 70f5732

3 files changed

Lines changed: 105 additions & 53 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,10 +1980,13 @@ def integration_uninstall(
19801980
raise typer.Exit(1)
19811981

19821982
if not integration:
1983-
console.print(f"[red]Error:[/red] Integration '{key}' not found in registry.")
1984-
raise typer.Exit(1)
1985-
1986-
removed, skipped = integration.teardown(project_root, manifest, force=force)
1983+
console.print(
1984+
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
1985+
"in registry. Falling back to manifest-based cleanup."
1986+
)
1987+
removed, skipped = manifest.uninstall(project_root, force=force)
1988+
else:
1989+
removed, skipped = integration.teardown(project_root, manifest, force=force)
19871990

19881991
remaining = [installed for installed in installed_keys if installed != key]
19891992
new_default = default_key if default_key != key else (remaining[0] if remaining else None)

src/specify_cli/integrations/hermes/__init__.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,23 @@ def teardown(
189189
*,
190190
force: bool = False,
191191
) -> tuple[list[Path], list[Path]]:
192-
"""Uninstall integration files and clean up global skills.
192+
"""Uninstall integration files and optionally clean up global skills.
193193
194194
Removes the managed context section from AGENTS.md, removes the
195-
project-local marker directory, and deletes all ``speckit-*``
196-
directories from ``~/.hermes/skills/``.
195+
project-local marker directory, and delegates to
196+
``manifest.uninstall()`` for project-local tracked files.
197+
198+
Global ``speckit-*`` skills under ``~/.hermes/skills/`` are only
199+
removed when ``force=True`` to avoid destroying skills shared with
200+
other Spec Kit projects.
197201
"""
198202
# Remove managed context section from AGENTS.md
199203
self.remove_context_section(project_root)
200204

205+
# Delegate to manifest for project-local tracked files (scripts,
206+
# templates, context entries tracked in the manifest).
207+
removed, skipped = manifest.uninstall(project_root, force=force)
208+
201209
# Remove project-local marker directory if empty
202210
local_skills_dir = project_root / ".hermes" / "skills"
203211
if local_skills_dir.is_dir() and not any(local_skills_dir.iterdir()):
@@ -206,18 +214,19 @@ def teardown(
206214
if hermes_dir.is_dir() and not any(hermes_dir.iterdir()):
207215
hermes_dir.rmdir()
208216

209-
# Remove global Hermes skills for speckit
210-
removed: list[Path] = []
211-
global_skills_dir = self._hermes_home_skills_dir()
212-
if global_skills_dir.is_dir():
213-
for skill_dir in sorted(global_skills_dir.iterdir()):
214-
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
215-
skill_file = skill_dir / "SKILL.md"
216-
if skill_file.exists():
217-
removed.append(skill_file)
218-
rmtree(skill_dir, ignore_errors=True)
219-
220-
return removed, []
217+
# Remove global Hermes skills for speckit — only when force=True
218+
# to avoid destroying skills shared with other Spec Kit projects.
219+
if force:
220+
global_skills_dir = self._hermes_home_skills_dir()
221+
if global_skills_dir.is_dir():
222+
for skill_dir in sorted(global_skills_dir.iterdir()):
223+
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
224+
skill_file = skill_dir / "SKILL.md"
225+
if skill_file.exists():
226+
removed.append(skill_file)
227+
rmtree(skill_dir, ignore_errors=True)
228+
229+
return removed, skipped
221230

222231
# -- CLI dispatch ------------------------------------------------------
223232

tests/integrations/test_integration_hermes.py

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,26 @@
55
``.hermes/skills/`` directory. A project-local marker (empty directory)
66
is 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

1213
from 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
1616
from specify_cli.integrations.manifest import IntegrationManifest
1717

1818
from .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+
2128
class 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):
236273
class 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

Comments
 (0)