Skip to content

Commit 0c975bb

Browse files
authored
fix: write Codex dev skills as files (#2988)
* fix: write Codex dev skills as files * fix: route codex dev symlink policy through metadata * fix: replace codex dev symlinks on refresh * fix: migrate codex dev skill symlinks * fix: avoid inactive shared skill dev symlinks * fix: preserve unrelated dev skill symlinks
1 parent 59ffa91 commit 0c975bb

7 files changed

Lines changed: 254 additions & 6 deletions

File tree

src/specify_cli/agents.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def _build_agent_configs() -> dict[str, Any]:
3737
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
3838
if "invoke_separator" not in config:
3939
config["invoke_separator"] = integration.invoke_separator
40+
if integration.dev_no_symlink:
41+
config["dev_no_symlink"] = True
4042
configs[key] = config
4143
return configs
4244

@@ -714,6 +716,7 @@ def register_commands(
714716
output_name,
715717
agent_config["extension"],
716718
link_outputs,
719+
agent_config,
717720
)
718721

719722
if agent_name == "copilot":
@@ -788,6 +791,7 @@ def register_commands(
788791
alias_output_name,
789792
agent_config["extension"],
790793
link_outputs,
794+
agent_config,
791795
)
792796
if agent_name == "copilot":
793797
self.write_copilot_prompt(project_root, alias)
@@ -804,9 +808,12 @@ def _write_registered_output(
804808
output_name: str,
805809
extension: str,
806810
link_outputs: bool,
811+
agent_config: dict[str, Any] | None = None,
807812
) -> None:
808813
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
809-
if not link_outputs:
814+
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
815+
if dest_file.is_symlink():
816+
dest_file.unlink()
810817
dest_file.write_text(content, encoding="utf-8")
811818
return
812819

@@ -927,6 +934,16 @@ def register_commands_for_all_agents(
927934
self._active_skills_agent(project_root)
928935
if create_missing_active_skills_dir else None
929936
)
937+
active_skills_dir: Optional[Path] = None
938+
if active_skills_agent:
939+
active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent)
940+
if (
941+
active_skills_config
942+
and active_skills_config.get("extension") == "/SKILL.md"
943+
):
944+
active_skills_dir = self._resolve_agent_dir(
945+
active_skills_agent, active_skills_config, project_root,
946+
)
930947
active_created_skills_dir: Optional[Path] = None
931948
for agent_name, agent_config in self.AGENT_CONFIGS.items():
932949
active_skills_output = (
@@ -958,6 +975,14 @@ def register_commands_for_all_agents(
958975
agent_dir = self._resolve_agent_dir(
959976
agent_name, agent_config, project_root,
960977
)
978+
shares_active_skills_dir = (
979+
active_skills_dir is not None
980+
and agent_name != active_skills_agent
981+
and agent_config.get("extension") == "/SKILL.md"
982+
and self._same_lexical_path(agent_dir, active_skills_dir)
983+
)
984+
if shares_active_skills_dir:
985+
continue
961986

962987
agent_dir_existed = agent_dir.is_dir()
963988
register_missing_active_skills_agent = (

src/specify_cli/extensions/__init__.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,7 @@ def _register_extension_skills(
997997
if not isinstance(selected_ai, str) or not selected_ai:
998998
return []
999999
registrar = CommandRegistrar()
1000+
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
10001001
integration = get_integration(selected_ai)
10011002

10021003
for cmd_info in manifest.commands:
@@ -1030,15 +1031,16 @@ def _register_extension_skills(
10301031
skill_file = skill_subdir / "SKILL.md"
10311032
cache_root = extension_dir / ".specify-dev" / "extension-skills"
10321033
cache_file = cache_root / skill_name / "SKILL.md"
1034+
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
10331035
CommandRegistrar._ensure_inside(cache_file, cache_root)
10341036
if skill_file.exists() or skill_file.is_symlink():
1037+
is_expected_dev_symlink = self._is_expected_dev_symlink(
1038+
skill_file, cache_file
1039+
)
10351040
# Do not overwrite user-customized skills, but allow dev-mode
10361041
# symlinks that point back to this extension's generated cache
10371042
# to be refreshed on a subsequent dev install.
1038-
if not (
1039-
link_outputs
1040-
and self._is_expected_dev_symlink(skill_file, cache_file)
1041-
):
1043+
if not is_expected_dev_symlink:
10421044
continue
10431045

10441046
# Create skill directory; track whether we created it so we can clean
@@ -1093,7 +1095,7 @@ def _register_extension_skills(
10931095
):
10941096
skill_content = integration.post_process_skill_content(skill_content)
10951097

1096-
if link_outputs:
1098+
if use_dev_symlink:
10971099
try:
10981100
cache_file.parent.mkdir(parents=True, exist_ok=True)
10991101
cache_file.write_text(skill_content, encoding="utf-8")
@@ -1106,6 +1108,8 @@ def _register_extension_skills(
11061108
skill_file.unlink()
11071109
skill_file.write_text(skill_content, encoding="utf-8")
11081110
else:
1111+
if skill_file.is_symlink():
1112+
skill_file.unlink()
11091113
skill_file.write_text(skill_content, encoding="utf-8")
11101114
written.append(skill_name)
11111115

src/specify_cli/integrations/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ class IntegrationBase(ABC):
119119
invoke_separator: str = "."
120120
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
121121

122+
dev_no_symlink: bool = False
123+
"""Whether dev-mode registration should write files instead of symlinks."""
124+
122125
multi_install_safe: bool = False
123126
"""Whether this integration is declared safe to install alongside others.
124127

src/specify_cli/integrations/codex/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration):
2727
"extension": "/SKILL.md",
2828
}
2929
context_file = "AGENTS.md"
30+
dev_no_symlink = True
3031
multi_install_safe = True
3132

3233
def build_exec_args(

tests/test_agent_config_consistency.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@ def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
360360
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
361361
)
362362

363+
def test_codex_dev_no_symlink_policy_in_agent_config(self):
364+
"""Codex dev installs must expose the no-symlink policy as metadata."""
365+
cfg = CommandRegistrar.AGENT_CONFIGS
366+
367+
assert cfg["codex"].get("dev_no_symlink") is True
368+
363369
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
364370
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
365371
when registered for a skills-based agent (e.g. claude).

tests/test_extension_skills.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,84 @@ def test_dev_skill_symlink_refreshes_existing_cache(
573573
assert "speckit-test-ext-hello" in written
574574
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
575575

576+
def test_codex_dev_skill_registration_replaces_existing_dev_symlink(
577+
self, project_dir, extension_dir, temp_dir
578+
):
579+
"""Codex dev skill registration should migrate prior dev symlinks to files."""
580+
if not _can_create_symlink(temp_dir):
581+
pytest.skip("Current platform/user cannot create symlinks")
582+
583+
_create_init_options(project_dir, ai="codex", ai_skills=True)
584+
skills_dir = _create_skills_dir(project_dir, ai="codex")
585+
manager = ExtensionManager(project_dir)
586+
manifest = ExtensionManifest(extension_dir / "extension.yml")
587+
588+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
589+
skill_file.parent.mkdir(parents=True, exist_ok=True)
590+
cache_file = (
591+
extension_dir
592+
/ ".specify-dev"
593+
/ "extension-skills"
594+
/ "speckit-test-ext-hello"
595+
/ "SKILL.md"
596+
)
597+
cache_file.parent.mkdir(parents=True)
598+
cache_file.write_text("old linked content", encoding="utf-8")
599+
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
600+
601+
written = manager._register_extension_skills(
602+
manifest,
603+
extension_dir,
604+
link_outputs=True,
605+
)
606+
607+
assert "speckit-test-ext-hello" in written
608+
assert skill_file.exists()
609+
assert not skill_file.is_symlink()
610+
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
611+
assert cache_file.read_text(encoding="utf-8") == "old linked content"
612+
613+
def test_codex_dev_skill_registration_preserves_unrelated_symlink(
614+
self, project_dir, extension_dir, temp_dir
615+
):
616+
"""Codex dev registration should not overwrite user-owned symlinks."""
617+
if not _can_create_symlink(temp_dir):
618+
pytest.skip("Current platform/user cannot create symlinks")
619+
620+
_create_init_options(project_dir, ai="codex", ai_skills=True)
621+
skills_dir = _create_skills_dir(project_dir, ai="codex")
622+
manager = ExtensionManager(project_dir)
623+
manifest = ExtensionManifest(extension_dir / "extension.yml")
624+
625+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
626+
skill_file.parent.mkdir(parents=True, exist_ok=True)
627+
unrelated_cache_file = (
628+
temp_dir
629+
/ "other-extension"
630+
/ ".specify-dev"
631+
/ "extension-skills"
632+
/ "speckit-test-ext-hello"
633+
/ "SKILL.md"
634+
)
635+
unrelated_cache_file.parent.mkdir(parents=True)
636+
unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8")
637+
os.symlink(
638+
os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file
639+
)
640+
641+
written = manager._register_extension_skills(
642+
manifest,
643+
extension_dir,
644+
link_outputs=True,
645+
)
646+
647+
assert "speckit-test-ext-hello" not in written
648+
assert skill_file.is_symlink()
649+
assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve()
650+
assert unrelated_cache_file.read_text(encoding="utf-8") == (
651+
"user-owned linked content"
652+
)
653+
576654
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
577655
self, skills_project, extension_dir, monkeypatch
578656
):

tests/test_extensions.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2248,6 +2248,50 @@ def test_dev_register_commands_symlinks_rendered_copilot_agent(
22482248
assert target.is_file()
22492249
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
22502250

2251+
def test_dev_register_commands_replaces_codex_dev_symlink(
2252+
self, extension_dir, project_dir, temp_dir
2253+
):
2254+
"""Codex dev registration should replace prior symlinks with real files."""
2255+
if not can_create_symlink(temp_dir):
2256+
pytest.skip("Current platform/user cannot create symlinks")
2257+
2258+
skill_file = (
2259+
project_dir
2260+
/ ".agents"
2261+
/ "skills"
2262+
/ "speckit-test-ext-hello"
2263+
/ "SKILL.md"
2264+
)
2265+
skill_file.parent.mkdir(parents=True)
2266+
cache_file = (
2267+
extension_dir
2268+
/ ".specify-dev"
2269+
/ "agent-commands"
2270+
/ "codex"
2271+
/ "speckit-test-ext-hello"
2272+
/ "SKILL.md"
2273+
)
2274+
cache_file.parent.mkdir(parents=True)
2275+
cache_file.write_text("old linked content", encoding="utf-8")
2276+
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
2277+
2278+
manifest = ExtensionManifest(extension_dir / "extension.yml")
2279+
registrar = CommandRegistrar()
2280+
registrar.register_commands_for_agent(
2281+
"codex",
2282+
manifest,
2283+
extension_dir,
2284+
project_dir,
2285+
link_outputs=True,
2286+
)
2287+
2288+
assert skill_file.exists()
2289+
assert not skill_file.is_symlink()
2290+
assert "name: speckit-test-ext-hello" in skill_file.read_text(
2291+
encoding="utf-8"
2292+
)
2293+
assert cache_file.read_text(encoding="utf-8") == "old linked content"
2294+
22512295
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
22522296
self, extension_dir, project_dir, monkeypatch
22532297
):
@@ -4874,6 +4918,93 @@ def test_add_dev_links_copilot_agent_when_supported(
48744918
else:
48754919
assert not agent_file.is_symlink()
48764920

4921+
def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
4922+
"""Codex dev skills should be written as files so Codex can load them."""
4923+
from typer.testing import CliRunner
4924+
from unittest.mock import patch
4925+
from specify_cli import app
4926+
4927+
init_options = project_dir / ".specify" / "init-options.json"
4928+
init_options.write_text(
4929+
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
4930+
)
4931+
4932+
runner = CliRunner()
4933+
with patch.object(Path, "cwd", return_value=project_dir):
4934+
result = runner.invoke(
4935+
app,
4936+
["extension", "add", str(extension_dir), "--dev"],
4937+
catch_exceptions=True,
4938+
)
4939+
4940+
assert result.exit_code == 0, result.output
4941+
4942+
skill_file = (
4943+
project_dir
4944+
/ ".agents"
4945+
/ "skills"
4946+
/ "speckit-test-ext-hello"
4947+
/ "SKILL.md"
4948+
)
4949+
assert skill_file.exists()
4950+
assert not skill_file.is_symlink()
4951+
4952+
content = skill_file.read_text(encoding="utf-8")
4953+
assert "name: speckit-test-ext-hello" in content
4954+
assert "metadata:" in content
4955+
assert "source: test-ext:commands/hello.md" in content
4956+
4957+
def test_add_dev_replaces_existing_codex_skill_symlink(
4958+
self, extension_dir, project_dir, temp_dir
4959+
):
4960+
"""Codex dev installs should migrate expected dev symlinks to files."""
4961+
if not can_create_symlink(temp_dir):
4962+
pytest.skip("Current platform/user cannot create symlinks")
4963+
4964+
from typer.testing import CliRunner
4965+
from unittest.mock import patch
4966+
from specify_cli import app
4967+
4968+
init_options = project_dir / ".specify" / "init-options.json"
4969+
init_options.write_text(
4970+
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
4971+
)
4972+
4973+
skill_file = (
4974+
project_dir
4975+
/ ".agents"
4976+
/ "skills"
4977+
/ "speckit-test-ext-hello"
4978+
/ "SKILL.md"
4979+
)
4980+
skill_file.parent.mkdir(parents=True)
4981+
cache_file = (
4982+
extension_dir
4983+
/ ".specify-dev"
4984+
/ "extension-skills"
4985+
/ "speckit-test-ext-hello"
4986+
/ "SKILL.md"
4987+
)
4988+
cache_file.parent.mkdir(parents=True)
4989+
cache_file.write_text("old linked content", encoding="utf-8")
4990+
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
4991+
4992+
runner = CliRunner()
4993+
with patch.object(Path, "cwd", return_value=project_dir):
4994+
result = runner.invoke(
4995+
app,
4996+
["extension", "add", str(extension_dir), "--dev"],
4997+
catch_exceptions=True,
4998+
)
4999+
5000+
assert result.exit_code == 0, result.output
5001+
assert skill_file.exists()
5002+
assert not skill_file.is_symlink()
5003+
content = skill_file.read_text(encoding="utf-8")
5004+
assert "name: speckit-test-ext-hello" in content
5005+
assert "source: test-ext:commands/hello.md" in content
5006+
assert cache_file.read_text(encoding="utf-8") == "old linked content"
5007+
48775008
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
48785009
self, extension_dir, project_dir, monkeypatch
48795010
):

0 commit comments

Comments
 (0)