Skip to content

Commit 5938c22

Browse files
committed
fix: address PR #2324 review feedback
- Reset _skills_mode at start of setup() to prevent singleton state leak - Tighten skills auto-detection to require speckit-*/SKILL.md (not any non-empty .github/skills/ directory) - Add copilot_skill_mode to init next-steps so skills mode renders /speckit-plan instead of /speckit.plan - Fix docstring quoting to match actual unquoted output - Add 4 tests covering singleton reset, auto-detection false positive, speckit layout detection, and next-steps skill syntax - Fix skipped test_invalid_metadata_error_returns_unknown by simulating InvalidMetadataError on Python versions that lack it
1 parent 5922707 commit 5938c22

4 files changed

Lines changed: 117 additions & 14 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,15 +1514,16 @@ def init(
15141514
# Determine skill display mode for the next-steps panel.
15151515
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
15161516
from .integrations.base import SkillsIntegration as _SkillsInt
1517-
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
1517+
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
15181518

15191519
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
15201520
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
15211521
kimi_skill_mode = selected_ai == "kimi"
15221522
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
15231523
trae_skill_mode = selected_ai == "trae"
15241524
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
1525-
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode
1525+
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
1526+
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode
15261527

15271528
if codex_skill_mode and not ai_skills:
15281529
# Integration path installed skills; show the helpful notice
@@ -1543,7 +1544,7 @@ def _display_cmd(name: str) -> str:
15431544
return f"/speckit-{name}"
15441545
if kimi_skill_mode:
15451546
return f"/skill:speckit-{name}"
1546-
if cursor_agent_skill_mode:
1547+
if cursor_agent_skill_mode or copilot_skill_mode:
15471548
return f"/speckit-{name}"
15481549
return f"/speckit.{name}"
15491550

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,11 @@ def dispatch_command(
182182
skills_mode = self._skills_mode
183183
if not skills_mode and project_root:
184184
skills_dir = project_root / ".github" / "skills"
185-
if skills_dir.is_dir() and any(skills_dir.iterdir()):
186-
skills_mode = True
185+
if skills_dir.is_dir():
186+
skills_mode = any(
187+
d.is_dir() and (d / "SKILL.md").is_file()
188+
for d in skills_dir.glob("speckit-*")
189+
)
187190

188191
if skills_mode:
189192
prompt = self.build_command_invocation(command_name, args)
@@ -242,7 +245,7 @@ def command_filename(self, template_name: str) -> str:
242245
def post_process_skill_content(self, content: str) -> str:
243246
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
244247
245-
Inserts ``mode: "speckit.<stem>"`` before the closing ``---`` so
248+
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
246249
Copilot can associate the skill with its agent mode.
247250
"""
248251
lines = content.splitlines(keepends=True)
@@ -306,8 +309,8 @@ def setup(
306309
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
307310
"""
308311
parsed_options = parsed_options or {}
309-
if parsed_options.get("skills"):
310-
self._skills_mode = True
312+
self._skills_mode = bool(parsed_options.get("skills"))
313+
if self._skills_mode:
311314
return self._setup_skills(project_root, manifest, parsed_options, **opts)
312315
return self._setup_default(project_root, manifest, parsed_options, **opts)
313316

tests/integrations/test_integration_copilot.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,4 +600,90 @@ def test_complete_file_inventory_skills_sh(self, tmp_path):
600600
assert actual == expected, (
601601
f"Missing: {sorted(set(expected) - set(actual))}\n"
602602
f"Extra: {sorted(set(actual) - set(expected))}"
603+
)
604+
605+
# -- Singleton leak: _skills_mode must reset --------------------------
606+
607+
def test_skills_mode_resets_on_default_setup(self, tmp_path):
608+
"""setup() with skills=True then without must reset _skills_mode."""
609+
copilot = self._make_copilot()
610+
611+
# First call: skills mode
612+
(tmp_path / "proj1").mkdir()
613+
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
614+
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
615+
assert copilot._skills_mode is True
616+
617+
# Second call: default mode (no skills option)
618+
(tmp_path / "proj2").mkdir()
619+
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
620+
copilot.setup(tmp_path / "proj2", m2)
621+
assert copilot._skills_mode is False
622+
623+
# build_command_invocation must use default (dotted) mode
624+
assert copilot.build_command_invocation("plan", "args") == "args"
625+
626+
# -- Auto-detection must ignore unrelated .github/skills/ -------------
627+
628+
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
629+
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
630+
copilot = self._make_copilot()
631+
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
632+
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
633+
unrelated.mkdir(parents=True)
634+
(unrelated / "README.md").write_text("# GitHub Skills training\n")
635+
636+
# Should NOT detect skills mode — cli_args should contain --agent
637+
import unittest.mock as mock
638+
with mock.patch("subprocess.run") as mock_run:
639+
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
640+
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
641+
call_args = mock_run.call_args[0][0]
642+
assert "--agent" in call_args, (
643+
f"Expected --agent in cli_args but got: {call_args}"
644+
)
645+
assert "speckit.plan" in call_args
646+
647+
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
648+
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
649+
copilot = self._make_copilot()
650+
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
651+
skill_dir.mkdir(parents=True)
652+
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
653+
654+
import unittest.mock as mock
655+
with mock.patch("subprocess.run") as mock_run:
656+
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
657+
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
658+
call_args = mock_run.call_args[0][0]
659+
assert "--agent" not in call_args, (
660+
f"Skills mode should not use --agent, got: {call_args}"
661+
)
662+
663+
# -- Next-steps display for Copilot skills mode -----------------------
664+
665+
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
666+
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
667+
from typer.testing import CliRunner
668+
from specify_cli import app
669+
project = tmp_path / "copilot-nextsteps"
670+
project.mkdir()
671+
old_cwd = os.getcwd()
672+
try:
673+
os.chdir(project)
674+
result = CliRunner().invoke(app, [
675+
"init", "--here", "--integration", "copilot",
676+
"--integration-options", "--skills",
677+
"--script", "sh", "--no-git",
678+
], catch_exceptions=False)
679+
finally:
680+
os.chdir(old_cwd)
681+
assert result.exit_code == 0, f"init failed: {result.output}"
682+
# Skills mode should show /speckit-plan (hyphenated)
683+
assert "/speckit-plan" in result.output, (
684+
f"Expected /speckit-plan in next steps but got:\n{result.output}"
685+
)
686+
# Must NOT show the dotted /speckit.plan form
687+
assert "/speckit.plan" not in result.output, (
688+
f"Should not show /speckit.plan in skills mode:\n{result.output}"
603689
)

tests/test_upgrade.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,25 @@ class TestInstalledVersion:
100100
def test_invalid_metadata_error_returns_unknown(self):
101101
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
102102
if invalid_metadata_error is None:
103-
pytest.skip("InvalidMetadataError is not available on this Python version")
104-
with patch(
105-
"importlib.metadata.version",
106-
side_effect=invalid_metadata_error("bad metadata"),
107-
):
108-
assert _get_installed_version() == "unknown"
103+
# Python versions without InvalidMetadataError: simulate with a
104+
# custom exception to verify the guarded except path works.
105+
class _FakeInvalidMetadataError(Exception):
106+
pass
107+
invalid_metadata_error = _FakeInvalidMetadataError
108+
# Patch the attribute onto importlib.metadata so the production
109+
# getattr() finds it during this test.
110+
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
111+
with patch(
112+
"importlib.metadata.version",
113+
side_effect=invalid_metadata_error("bad metadata"),
114+
):
115+
assert _get_installed_version() == "unknown"
116+
else:
117+
with patch(
118+
"importlib.metadata.version",
119+
side_effect=invalid_metadata_error("bad metadata"),
120+
):
121+
assert _get_installed_version() == "unknown"
109122

110123

111124
class TestNormalizeTag:

0 commit comments

Comments
 (0)