diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 3c06418014..e44314f62a 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -36,6 +36,8 @@ def _build_agent_configs() -> dict[str, Any]: # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. if "invoke_separator" not in config: config["invoke_separator"] = integration.invoke_separator + if integration.dev_no_symlink: + config["dev_no_symlink"] = True configs[key] = config return configs @@ -641,6 +643,7 @@ def register_commands( output_name, agent_config["extension"], link_outputs, + agent_config, ) if agent_name == "copilot": @@ -715,6 +718,7 @@ def register_commands( alias_output_name, agent_config["extension"], link_outputs, + agent_config, ) if agent_name == "copilot": self.write_copilot_prompt(project_root, alias) @@ -731,9 +735,12 @@ def _write_registered_output( output_name: str, extension: str, link_outputs: bool, + agent_config: dict[str, Any] | None = None, ) -> None: """Write a rendered agent artifact, optionally as a dev-mode symlink.""" - if not link_outputs: + if not link_outputs or (agent_config or {}).get("dev_no_symlink"): + if dest_file.is_symlink(): + dest_file.unlink() dest_file.write_text(content, encoding="utf-8") return diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index db53b7997f..1005f2df96 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -979,6 +979,7 @@ def _register_extension_skills( if not isinstance(selected_ai, str) or not selected_ai: return [] registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) integration = get_integration(selected_ai) for cmd_info in manifest.commands: @@ -1012,15 +1013,16 @@ def _register_extension_skills( skill_file = skill_subdir / "SKILL.md" cache_root = extension_dir / ".specify-dev" / "extension-skills" cache_file = cache_root / skill_name / "SKILL.md" + use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink") CommandRegistrar._ensure_inside(cache_file, cache_root) if skill_file.exists() or skill_file.is_symlink(): + is_expected_dev_symlink = self._is_expected_dev_symlink( + skill_file, cache_file + ) # Do not overwrite user-customized skills, but allow dev-mode # symlinks that point back to this extension's generated cache # to be refreshed on a subsequent dev install. - if not ( - link_outputs - and self._is_expected_dev_symlink(skill_file, cache_file) - ): + if not is_expected_dev_symlink: continue # Create skill directory; track whether we created it so we can clean @@ -1089,7 +1091,7 @@ def _register_extension_skills( skill_content ) - if link_outputs: + if use_dev_symlink: try: cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(skill_content, encoding="utf-8") @@ -1102,6 +1104,8 @@ def _register_extension_skills( skill_file.unlink() skill_file.write_text(skill_content, encoding="utf-8") else: + if skill_file.is_symlink(): + skill_file.unlink() skill_file.write_text(skill_content, encoding="utf-8") written.append(skill_name) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index def5ad20ba..1dc2b78712 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -113,6 +113,9 @@ class IntegrationBase(ABC): invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + dev_no_symlink: bool = False + """Whether dev-mode registration should write files instead of symlinks.""" + multi_install_safe: bool = False """Whether this integration is declared safe to install alongside others. diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 1f7dbc601f..4dd79da493 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + dev_no_symlink = True multi_install_safe = True def build_exec_args( diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 1176009778..359bdaedf8 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -217,6 +217,12 @@ def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self): "expected '-' (propagated from SkillsIntegration.invoke_separator)" ) + def test_codex_dev_no_symlink_policy_in_agent_config(self): + """Codex dev installs must expose the no-symlink policy as metadata.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert cfg["codex"].get("dev_no_symlink") is True + def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): """__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit- when registered for a skills-based agent (e.g. claude). diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1d05e1c2c4..0fdedd62e8 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2164,6 +2164,50 @@ def test_dev_register_commands_symlinks_rendered_copilot_agent( assert target.is_file() assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8") + def test_dev_register_commands_replaces_codex_dev_symlink( + self, extension_dir, project_dir, temp_dir + ): + """Codex dev registration should replace prior symlinks with real files.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "agent-commands" + / "codex" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "codex", + manifest, + extension_dir, + project_dir, + link_outputs=True, + ) + + assert skill_file.exists() + assert not skill_file.is_symlink() + assert "name: speckit-test-ext-hello" in skill_file.read_text( + encoding="utf-8" + ) + assert cache_file.read_text(encoding="utf-8") == "old linked content" + def test_dev_register_commands_falls_back_to_copy_when_symlink_fails( self, extension_dir, project_dir, monkeypatch ): @@ -4619,6 +4663,93 @@ def test_add_dev_links_copilot_agent_when_supported( else: assert not agent_file.is_symlink() + def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir): + """Codex dev skills should be written as files so Codex can load them.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + assert skill_file.exists() + assert not skill_file.is_symlink() + + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "metadata:" in content + assert "source: test-ext:commands/hello.md" in content + + def test_add_dev_replaces_existing_codex_skill_symlink( + self, extension_dir, project_dir, temp_dir + ): + """Codex dev installs should migrate expected dev symlinks to files.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "extension-skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert skill_file.exists() + assert not skill_file.is_symlink() + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "source: test-ext:commands/hello.md" in content + assert cache_file.read_text(encoding="utf-8") == "old linked content" + def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable( self, extension_dir, project_dir, monkeypatch ):