Skip to content

Commit 8d6fe45

Browse files
Address dev symlink review feedback
1 parent a5ad9c6 commit 8d6fe45

4 files changed

Lines changed: 106 additions & 17 deletions

File tree

src/specify_cli/agents.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -668,13 +668,9 @@ def _write_registered_output(
668668
return
669669

670670
rel_output = Path(f"{output_name}{extension}")
671-
cache_file = (
672-
source_dir
673-
/ ".specify-dev"
674-
/ "agent-commands"
675-
/ agent_name
676-
/ rel_output
677-
)
671+
cache_root = source_dir / ".specify-dev" / "agent-commands" / agent_name
672+
cache_file = cache_root / rel_output
673+
CommandRegistrar._ensure_inside(cache_file, cache_root)
678674
cache_file.parent.mkdir(parents=True, exist_ok=True)
679675
cache_file.write_text(content, encoding="utf-8")
680676

src/specify_cli/extensions.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -906,9 +906,18 @@ def _register_extension_skills(
906906
# Check if skill already exists before creating the directory
907907
skill_subdir = skills_dir / skill_name
908908
skill_file = skill_subdir / "SKILL.md"
909-
if skill_file.exists():
910-
# Do not overwrite user-customized skills
911-
continue
909+
cache_root = extension_dir / ".specify-dev" / "extension-skills"
910+
cache_file = cache_root / skill_name / "SKILL.md"
911+
CommandRegistrar._ensure_inside(cache_file, cache_root)
912+
if skill_file.exists() or skill_file.is_symlink():
913+
# Do not overwrite user-customized skills, but allow dev-mode
914+
# symlinks that point back to this extension's generated cache
915+
# to be refreshed on a subsequent dev install.
916+
if not (
917+
link_outputs
918+
and self._is_expected_dev_symlink(skill_file, cache_file)
919+
):
920+
continue
912921

913922
# Create skill directory; track whether we created it so we can clean
914923
# up safely if reading the source file subsequently fails.
@@ -961,13 +970,6 @@ def _register_extension_skills(
961970
)
962971

963972
if link_outputs:
964-
cache_file = (
965-
extension_dir
966-
/ ".specify-dev"
967-
/ "extension-skills"
968-
/ skill_name
969-
/ "SKILL.md"
970-
)
971973
cache_file.parent.mkdir(parents=True, exist_ok=True)
972974
cache_file.write_text(skill_content, encoding="utf-8")
973975
try:
@@ -985,6 +987,17 @@ def _register_extension_skills(
985987

986988
return written
987989

990+
@staticmethod
991+
def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
992+
"""Return True when an existing skill file links to its dev cache."""
993+
if not skill_file.is_symlink():
994+
return False
995+
996+
try:
997+
return skill_file.resolve(strict=False) == cache_file.resolve(strict=False)
998+
except OSError:
999+
return False
1000+
9881001
def _unregister_extension_skills(
9891002
self,
9901003
skill_names: List[str],

tests/test_extension_skills.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import json
14+
import os
1415
import pytest
1516
import tempfile
1617
import shutil
@@ -116,6 +117,18 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
116117
return ext_dir
117118

118119

120+
def _can_create_symlink(temp_dir: Path) -> bool:
121+
"""Return True when the current platform/user can create file symlinks."""
122+
target = temp_dir / "symlink-target.txt"
123+
link = temp_dir / "symlink-link.txt"
124+
target.write_text("ok", encoding="utf-8")
125+
try:
126+
os.symlink(target, link)
127+
except OSError:
128+
return False
129+
return link.is_symlink()
130+
131+
119132
# ===== Fixtures =====
120133

121134
@pytest.fixture
@@ -316,6 +329,46 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
316329
# The pre-existing one should NOT be in registered_skills (it was skipped)
317330
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
318331

332+
def test_dev_skill_symlink_refreshes_existing_cache(
333+
self, skills_project, extension_dir, temp_dir
334+
):
335+
"""Dev-mode skill symlinks should refresh rendered cache content."""
336+
if not _can_create_symlink(temp_dir):
337+
pytest.skip("Current platform/user cannot create symlinks")
338+
339+
project_dir, skills_dir = skills_project
340+
manager = ExtensionManager(project_dir)
341+
manifest = ExtensionManifest(extension_dir / "extension.yml")
342+
343+
manager._register_extension_skills(
344+
manifest,
345+
extension_dir,
346+
link_outputs=True,
347+
)
348+
349+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
350+
assert skill_file.is_symlink()
351+
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
352+
353+
(extension_dir / "commands" / "hello.md").write_text(
354+
"---\n"
355+
"description: \"Updated test hello command\"\n"
356+
"---\n"
357+
"\n"
358+
"# Hello Command\n"
359+
"\n"
360+
"Run this updated hello.\n"
361+
)
362+
363+
written = manager._register_extension_skills(
364+
manifest,
365+
extension_dir,
366+
link_outputs=True,
367+
)
368+
369+
assert "speckit-test-ext-hello" in written
370+
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
371+
319372
def test_registered_skills_in_registry(self, skills_project, extension_dir):
320373
"""Registry should contain registered_skills list."""
321374
project_dir, skills_dir = skills_project

tests/test_extensions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,6 +1799,33 @@ def raise_symlink_error(target, link):
17991799
/ "speckit.test-ext.hello.agent.md"
18001800
).exists()
18011801

1802+
def test_dev_register_commands_rejects_cache_path_traversal(self, temp_dir):
1803+
"""Dev-mode cache writes must stay inside the agent cache root."""
1804+
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
1805+
1806+
source_dir = temp_dir / "extension"
1807+
source_dir.mkdir()
1808+
commands_dir = temp_dir / "commands"
1809+
commands_dir.mkdir()
1810+
1811+
with pytest.raises(ValueError, match="escapes directory"):
1812+
AgentCommandRegistrar._write_registered_output(
1813+
commands_dir / "safe.md",
1814+
"content",
1815+
source_dir,
1816+
"copilot",
1817+
"../escaped",
1818+
".md",
1819+
True,
1820+
)
1821+
1822+
assert not (
1823+
source_dir
1824+
/ ".specify-dev"
1825+
/ "agent-commands"
1826+
/ "escaped.md"
1827+
).exists()
1828+
18021829
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
18031830
"""Test that companion .prompt.md files are created in .github/prompts/."""
18041831
agents_dir = project_dir / ".github" / "agents"

0 commit comments

Comments
 (0)