diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 3c06418014..f46d0b0236 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,10 @@ 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"): 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..e124386e53 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,13 +1013,14 @@ 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(): # 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 + use_dev_symlink and self._is_expected_dev_symlink(skill_file, cache_file) ): continue @@ -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") 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..cdfdad67c8 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -4619,6 +4619,42 @@ 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_falls_back_to_copy_when_windows_symlinks_unavailable( self, extension_dir, project_dir, monkeypatch ):