From a5ad9c61bc275eff00e4fac9e7ae51743fa013f1 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Thu, 14 May 2026 11:16:05 +0700 Subject: [PATCH 1/2] Fix dev extension agent symlinks --- src/specify_cli/__init__.py | 7 ++- src/specify_cli/agents.py | 72 +++++++++++++++++++++- src/specify_cli/extensions.py | 53 +++++++++++++--- tests/test_extensions.py | 110 ++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d4e8632215..2c2c0193ee 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3710,7 +3710,12 @@ def extension_add( console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") raise typer.Exit(1) - manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) + manifest = manager.install_from_directory( + source_path, + speckit_version, + priority=priority, + link_commands=True, + ) elif from_url: # Install from URL (ZIP file) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index a1be34dcc2..0ef17e1006 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -439,6 +439,7 @@ def register_commands( project_root: Path, context_note: str = None, _resolved_dir: Path = None, + link_outputs: bool = False, ) -> List[str]: """Register commands for a specific agent. @@ -453,6 +454,9 @@ def register_commands( only — avoids a second ``_resolve_agent_dir`` call and duplicate deprecation warnings when invoked from ``register_commands_for_all_agents``). + link_outputs: If True, write rendered output to a source-local + dev cache and symlink the agent command file to it. Falls back + to a normal file write when symlinks are unavailable. Returns: List of registered command names @@ -559,7 +563,15 @@ def register_commands( dest_file = commands_dir / f"{output_name}{agent_config['extension']}" self._ensure_inside(dest_file, commands_dir) dest_file.parent.mkdir(parents=True, exist_ok=True) - dest_file.write_text(output, encoding="utf-8") + self._write_registered_output( + dest_file, + output, + source_dir, + agent_name, + output_name, + agent_config["extension"], + link_outputs, + ) if agent_name == "copilot": self.write_copilot_prompt(project_root, cmd_name) @@ -625,13 +637,59 @@ def register_commands( ) self._ensure_inside(alias_file, commands_dir) alias_file.parent.mkdir(parents=True, exist_ok=True) - alias_file.write_text(alias_output, encoding="utf-8") + self._write_registered_output( + alias_file, + alias_output, + source_dir, + agent_name, + alias_output_name, + agent_config["extension"], + link_outputs, + ) if agent_name == "copilot": self.write_copilot_prompt(project_root, alias) registered.append(alias) return registered + @staticmethod + def _write_registered_output( + dest_file: Path, + content: str, + source_dir: Path, + agent_name: str, + output_name: str, + extension: str, + link_outputs: bool, + ) -> None: + """Write a rendered agent artifact, optionally as a dev-mode symlink.""" + if not link_outputs: + dest_file.write_text(content, encoding="utf-8") + return + + rel_output = Path(f"{output_name}{extension}") + cache_file = ( + source_dir + / ".specify-dev" + / "agent-commands" + / agent_name + / rel_output + ) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(content, encoding="utf-8") + + try: + if dest_file.exists() or dest_file.is_symlink(): + dest_file.unlink() + target = os.path.relpath(cache_file, dest_file.parent) + os.symlink(target, dest_file) + except OSError: + # Windows often requires Developer Mode or admin privileges for + # symlinks. Keep dev installs functional by falling back to a copy. + if dest_file.is_symlink(): + dest_file.unlink() + dest_file.write_text(content, encoding="utf-8") + @staticmethod def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: """Generate a companion .prompt.md file for a Copilot agent command. @@ -687,6 +745,7 @@ def register_commands_for_all_agents( source_dir: Path, project_root: Path, context_note: str = None, + link_outputs: bool = False, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -696,6 +755,8 @@ def register_commands_for_all_agents( source_dir: Directory containing command source files project_root: Path to project root context_note: Custom context comment for markdown output + link_outputs: If True, create dev-mode symlinks for rendered + command files when supported by the OS. Returns: Dictionary mapping agent names to list of registered commands @@ -718,6 +779,7 @@ def register_commands_for_all_agents( project_root, context_note=context_note, _resolved_dir=agent_dir, + link_outputs=link_outputs, ) if registered: results[agent_name] = registered @@ -733,6 +795,7 @@ def register_commands_for_non_skill_agents( source_dir: Path, project_root: Path, context_note: Optional[str] = None, + link_outputs: bool = False, ) -> Dict[str, List[str]]: """Register commands for all non-skill agents in the project. @@ -746,6 +809,8 @@ def register_commands_for_non_skill_agents( source_dir: Directory containing command source files project_root: Path to project root context_note: Custom context comment for markdown output + link_outputs: If True, create dev-mode symlinks for rendered + command files when supported by the OS. Returns: Dictionary mapping agent names to list of registered commands @@ -768,6 +833,7 @@ def register_commands_for_non_skill_agents( project_root, context_note=context_note, _resolved_dir=agent_dir, + link_outputs=link_outputs, ) if registered: results[agent_name] = registered @@ -816,7 +882,7 @@ def unregister_commands( cmd_file = ( target_dir / f"{output_name}{agent_config['extension']}" ) - if cmd_file.exists(): + if cmd_file.exists() or cmd_file.is_symlink(): cmd_file.unlink() # For SKILL.md agents each command lives in its own # subdirectory (e.g. .agents/skills/speckit-ext-cmd/ diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index f657de06ce..50aeb29952 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -840,6 +840,7 @@ def _register_extension_skills( self, manifest: ExtensionManifest, extension_dir: Path, + link_outputs: bool = False, ) -> List[str]: """Generate SKILL.md files for extension commands as agent skills. @@ -851,6 +852,8 @@ def _register_extension_skills( Args: manifest: Extension manifest. extension_dir: Installed extension directory. + link_outputs: If True, create dev-mode symlinks for rendered + skill files when supported by the OS. Returns: List of skill names that were created (for registry storage). @@ -957,7 +960,27 @@ def _register_extension_skills( skill_content ) - skill_file.write_text(skill_content, encoding="utf-8") + if link_outputs: + cache_file = ( + extension_dir + / ".specify-dev" + / "extension-skills" + / skill_name + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(skill_content, encoding="utf-8") + try: + if skill_file.exists() or skill_file.is_symlink(): + skill_file.unlink() + target = os.path.relpath(cache_file, skill_file.parent) + os.symlink(target, skill_file) + except OSError: + if skill_file.is_symlink(): + skill_file.unlink() + skill_file.write_text(skill_content, encoding="utf-8") + else: + skill_file.write_text(skill_content, encoding="utf-8") written.append(skill_name) return written @@ -1132,6 +1155,7 @@ def install_from_directory( speckit_version: str, register_commands: bool = True, priority: int = 10, + link_commands: bool = False, ) -> ExtensionManifest: """Install extension from a local directory. @@ -1140,6 +1164,8 @@ def install_from_directory( speckit_version: Current spec-kit version register_commands: If True, register commands with AI agents priority: Resolution priority (lower = higher precedence, default 10) + link_commands: If True, register rendered agent artifacts as + symlinks to a dev cache when supported by the OS. Returns: Installed extension manifest @@ -1183,12 +1209,14 @@ def install_from_directory( registrar = CommandRegistrar() # Register for all detected agents registered_commands = registrar.register_commands_for_all_agents( - manifest, dest_dir, self.project_root + manifest, dest_dir, self.project_root, link_outputs=link_commands ) # Auto-register extension commands as agent skills when --ai-skills # was used during project initialisation (feature parity). - registered_skills = self._register_extension_skills(manifest, dest_dir) + registered_skills = self._register_extension_skills( + manifest, dest_dir, link_outputs=link_commands + ) # Register hooks and update installed list in extensions.yml hook_executor = HookExecutor(self.project_root) @@ -1624,7 +1652,8 @@ def register_commands_for_agent( agent_name: str, manifest: ExtensionManifest, extension_dir: Path, - project_root: Path + project_root: Path, + link_outputs: bool = False, ) -> List[str]: """Register extension commands for a specific agent.""" if agent_name not in self.AGENT_CONFIGS: @@ -1632,20 +1661,23 @@ def register_commands_for_agent( context_note = f"\n\n\n" return self._registrar.register_commands( agent_name, manifest.commands, manifest.id, extension_dir, project_root, - context_note=context_note + context_note=context_note, + link_outputs=link_outputs, ) def register_commands_for_all_agents( self, manifest: ExtensionManifest, extension_dir: Path, - project_root: Path + project_root: Path, + link_outputs: bool = False, ) -> Dict[str, List[str]]: """Register extension commands for all detected agents.""" context_note = f"\n\n\n" return self._registrar.register_commands_for_all_agents( manifest.commands, manifest.id, extension_dir, project_root, - context_note=context_note + context_note=context_note, + link_outputs=link_outputs, ) def unregister_commands( @@ -1660,10 +1692,13 @@ def register_commands_for_claude( self, manifest: ExtensionManifest, extension_dir: Path, - project_root: Path + project_root: Path, + link_outputs: bool = False, ) -> List[str]: """Register extension commands for Claude Code agent.""" - return self.register_commands_for_agent("claude", manifest, extension_dir, project_root) + return self.register_commands_for_agent( + "claude", manifest, extension_dir, project_root, link_outputs=link_outputs + ) class ExtensionCatalog: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1434ba309d..954dde308b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ import pytest import json +import os import platform import tempfile import shutil @@ -36,6 +37,18 @@ ) +def can_create_symlink(tmp_path: Path) -> bool: + """Return True when the current platform/user can create file symlinks.""" + target = tmp_path / "symlink-target.txt" + link = tmp_path / "symlink-link.txt" + target.write_text("ok", encoding="utf-8") + try: + os.symlink(target, link) + except OSError: + return False + return link.is_symlink() + + # ===== Fixtures ===== @pytest.fixture @@ -1722,6 +1735,70 @@ def test_register_commands_for_copilot(self, extension_dir, project_dir): assert "description: Test hello command" in content assert "test-ext" in content + def test_dev_register_commands_symlinks_rendered_copilot_agent( + self, extension_dir, project_dir, temp_dir + ): + """Dev-mode registration should symlink agent files to rendered outputs.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_agent( + "copilot", + manifest, + extension_dir, + project_dir, + link_outputs=True, + ) + + assert registered == ["speckit.test-ext.hello"] + + cmd_file = agents_dir / "speckit.test-ext.hello.agent.md" + assert cmd_file.is_symlink() + + target = cmd_file.resolve() + assert ".specify-dev" in target.parts + assert target.is_file() + assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8") + + def test_dev_register_commands_falls_back_to_copy_when_symlink_fails( + self, extension_dir, project_dir, monkeypatch + ): + """Dev-mode registration stays functional when symlinks are unavailable.""" + agents_dir = project_dir / ".github" / "agents" + agents_dir.mkdir(parents=True) + + def raise_symlink_error(target, link): + raise OSError("symlink unavailable") + + monkeypatch.setattr("specify_cli.agents.os.symlink", raise_symlink_error) + + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent( + "copilot", + manifest, + extension_dir, + project_dir, + link_outputs=True, + ) + + cmd_file = agents_dir / "speckit.test-ext.hello.agent.md" + assert cmd_file.exists() + assert not cmd_file.is_symlink() + assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8") + assert ( + extension_dir + / ".specify-dev" + / "agent-commands" + / "copilot" + / "speckit.test-ext.hello.agent.md" + ).exists() + def test_copilot_companion_prompt_created(self, extension_dir, project_dir): """Test that companion .prompt.md files are created in .github/prompts/.""" agents_dir = project_dir / ".github" / "agents" @@ -3353,6 +3430,39 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): class TestExtensionAddCLI: """CLI integration tests for extension add command.""" + def test_add_dev_links_copilot_agent_when_supported( + self, extension_dir, project_dir, temp_dir + ): + """extension add --dev should link generated agent files when possible.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + (project_dir / ".github" / "agents").mkdir(parents=True) + + 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 + + agent_file = ( + project_dir + / ".github" + / "agents" + / "speckit.test-ext.hello.agent.md" + ) + assert agent_file.exists() + if can_create_symlink(temp_dir): + assert agent_file.is_symlink() + assert ".specify-dev" in agent_file.resolve().parts + else: + assert not agent_file.is_symlink() + def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path): """extension add by display name should use resolved ID for download_extension().""" from typer.testing import CliRunner From 8d6fe45eb5a5545242569c226b13590eb2053c7d Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Thu, 14 May 2026 20:44:41 +0700 Subject: [PATCH 2/2] Address dev symlink review feedback --- src/specify_cli/agents.py | 10 ++----- src/specify_cli/extensions.py | 33 ++++++++++++++------- tests/test_extension_skills.py | 53 ++++++++++++++++++++++++++++++++++ tests/test_extensions.py | 27 +++++++++++++++++ 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 0ef17e1006..112abc27ef 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -668,13 +668,9 @@ def _write_registered_output( return rel_output = Path(f"{output_name}{extension}") - cache_file = ( - source_dir - / ".specify-dev" - / "agent-commands" - / agent_name - / rel_output - ) + cache_root = source_dir / ".specify-dev" / "agent-commands" / agent_name + cache_file = cache_root / rel_output + CommandRegistrar._ensure_inside(cache_file, cache_root) cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(content, encoding="utf-8") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 50aeb29952..dcf5133946 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -906,9 +906,18 @@ def _register_extension_skills( # Check if skill already exists before creating the directory skill_subdir = skills_dir / skill_name skill_file = skill_subdir / "SKILL.md" - if skill_file.exists(): - # Do not overwrite user-customized skills - continue + cache_root = extension_dir / ".specify-dev" / "extension-skills" + cache_file = cache_root / skill_name / "SKILL.md" + 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 + and self._is_expected_dev_symlink(skill_file, cache_file) + ): + continue # Create skill directory; track whether we created it so we can clean # up safely if reading the source file subsequently fails. @@ -961,13 +970,6 @@ def _register_extension_skills( ) if link_outputs: - cache_file = ( - extension_dir - / ".specify-dev" - / "extension-skills" - / skill_name - / "SKILL.md" - ) cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(skill_content, encoding="utf-8") try: @@ -985,6 +987,17 @@ def _register_extension_skills( return written + @staticmethod + def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool: + """Return True when an existing skill file links to its dev cache.""" + if not skill_file.is_symlink(): + return False + + try: + return skill_file.resolve(strict=False) == cache_file.resolve(strict=False) + except OSError: + return False + def _unregister_extension_skills( self, skill_names: List[str], diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 89e8b4a8b8..4c9e77b82c 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -11,6 +11,7 @@ """ import json +import os import pytest import tempfile import shutil @@ -116,6 +117,18 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path: return ext_dir +def _can_create_symlink(temp_dir: Path) -> bool: + """Return True when the current platform/user can create file symlinks.""" + target = temp_dir / "symlink-target.txt" + link = temp_dir / "symlink-link.txt" + target.write_text("ok", encoding="utf-8") + try: + os.symlink(target, link) + except OSError: + return False + return link.is_symlink() + + # ===== Fixtures ===== @pytest.fixture @@ -316,6 +329,46 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir): # The pre-existing one should NOT be in registered_skills (it was skipped) assert "speckit-test-ext-hello" not in metadata["registered_skills"] + def test_dev_skill_symlink_refreshes_existing_cache( + self, skills_project, extension_dir, temp_dir + ): + """Dev-mode skill symlinks should refresh rendered cache content.""" + if not _can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = ExtensionManifest(extension_dir / "extension.yml") + + manager._register_extension_skills( + manifest, + extension_dir, + link_outputs=True, + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + assert skill_file.is_symlink() + assert "Run this to say hello." in skill_file.read_text(encoding="utf-8") + + (extension_dir / "commands" / "hello.md").write_text( + "---\n" + "description: \"Updated test hello command\"\n" + "---\n" + "\n" + "# Hello Command\n" + "\n" + "Run this updated hello.\n" + ) + + written = manager._register_extension_skills( + manifest, + extension_dir, + link_outputs=True, + ) + + assert "speckit-test-ext-hello" in written + assert "Run this updated hello." in skill_file.read_text(encoding="utf-8") + def test_registered_skills_in_registry(self, skills_project, extension_dir): """Registry should contain registered_skills list.""" project_dir, skills_dir = skills_project diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 954dde308b..bd0849a3cb 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1799,6 +1799,33 @@ def raise_symlink_error(target, link): / "speckit.test-ext.hello.agent.md" ).exists() + def test_dev_register_commands_rejects_cache_path_traversal(self, temp_dir): + """Dev-mode cache writes must stay inside the agent cache root.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + source_dir = temp_dir / "extension" + source_dir.mkdir() + commands_dir = temp_dir / "commands" + commands_dir.mkdir() + + with pytest.raises(ValueError, match="escapes directory"): + AgentCommandRegistrar._write_registered_output( + commands_dir / "safe.md", + "content", + source_dir, + "copilot", + "../escaped", + ".md", + True, + ) + + assert not ( + source_dir + / ".specify-dev" + / "agent-commands" + / "escaped.md" + ).exists() + def test_copilot_companion_prompt_created(self, extension_dir, project_dir): """Test that companion .prompt.md files are created in .github/prompts/.""" agents_dir = project_dir / ".github" / "agents"