Skip to content

Commit ac580bd

Browse files
committed
Harden skill path and preset checks
1 parent 3a63863 commit ac580bd

5 files changed

Lines changed: 46 additions & 8 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2151,10 +2151,6 @@ def init(
21512151

21522152
if ai_skills:
21532153
if selected_ai in NATIVE_SKILLS_AGENTS:
2154-
if skills_dir is None:
2155-
raise RuntimeError(
2156-
f"Could not resolve skills directory for agent: {selected_ai}"
2157-
)
21582154
bundled_found = _has_bundled_skills(project_path, selected_ai)
21592155
if bundled_found:
21602156
detail = f"bundled skills → {skills_dir.relative_to(project_path)}"

src/specify_cli/agents.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,11 @@ def _rewrite_project_relative_paths(text: str) -> str:
251251
):
252252
text = text.replace(old, new)
253253

254-
text = re.sub(r"(?<![\w.])/?memory/", ".specify/memory/", text)
255-
text = re.sub(r"(?<![\w.])/?scripts/", ".specify/scripts/", text)
256-
text = re.sub(r"(?<![\w.])/?templates/", ".specify/templates/", text)
254+
# Only rewrite top-level style references so extension-local paths like
255+
# ".specify/extensions/<ext>/scripts/..." remain intact.
256+
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
257+
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
258+
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
257259

258260
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
259261

src/specify_cli/presets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ def _get_skills_dir(self) -> Optional[Path]:
574574

575575
opts = load_init_options(self.project_root)
576576
agent = opts.get("ai")
577-
if not agent:
577+
if not isinstance(agent, str) or not agent:
578578
return None
579579

580580
ai_skills_enabled = bool(opts.get("ai_skills"))

tests/test_extensions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,36 @@ def test_adjust_script_paths_does_not_mutate_input(self):
778778
assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}"
779779
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
780780

781+
def test_adjust_script_paths_preserves_extension_local_paths(self):
782+
"""Extension-local script paths should not be rewritten into .specify/.specify."""
783+
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
784+
registrar = AgentCommandRegistrar()
785+
original = {
786+
"scripts": {
787+
"sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}",
788+
"ps": "scripts/powershell/setup-plan.ps1 {ARGS}",
789+
}
790+
}
791+
792+
adjusted = registrar._adjust_script_paths(original)
793+
794+
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
795+
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
796+
797+
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
798+
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
799+
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
800+
801+
body = (
802+
"Read `.specify/extensions/test-ext/templates/spec.md`\n"
803+
"Run scripts/bash/setup-plan.sh\n"
804+
)
805+
806+
rewritten = AgentCommandRegistrar._rewrite_project_relative_paths(body)
807+
808+
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
809+
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
810+
781811
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
782812
"""TOML renderer should stay valid when body includes triple double-quotes."""
783813
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar

tests/test_presets.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1995,6 +1995,16 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
19951995
content = skill_file.read_text()
19961996
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
19971997

1998+
def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
1999+
"""Corrupted init-options ai values should not crash preset skill resolution."""
2000+
init_options = project_dir / ".specify" / "init-options.json"
2001+
init_options.parent.mkdir(parents=True, exist_ok=True)
2002+
init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
2003+
2004+
manager = PresetManager(project_dir)
2005+
2006+
assert manager._get_skills_dir() is None
2007+
19982008
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
19992009
"""When no init-options.json exists, preset install should not touch skills."""
20002010
skills_dir = project_dir / ".claude" / "skills"

0 commit comments

Comments
 (0)