Fix --dev extension agent symlinks (#2554)

* Fix dev extension agent symlinks

* Address dev symlink review feedback

* fix: handle dev symlink relpath failures

* fix: fall back when dev cache writes fail

* test: cover dev symlink fallback without privileges
This commit is contained in:
NgoQuocViet2001
2026-05-28 21:29:17 +07:00
committed by GitHub
parent 5a678c552e
commit 9307093d8a
5 changed files with 543 additions and 16 deletions

View File

@@ -2971,7 +2971,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)

View File

@@ -439,6 +439,7 @@ class CommandRegistrar:
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 @@ class CommandRegistrar:
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 @@ class CommandRegistrar:
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,56 @@ class CommandRegistrar:
)
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_root = source_dir / ".specify-dev" / "agent-commands" / agent_name
cache_file = cache_root / rel_output
CommandRegistrar._ensure_inside(cache_file, cache_root)
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(content, encoding="utf-8")
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, ValueError):
# Windows often requires Developer Mode or admin privileges for
# symlinks, and relpath can fail across drives. 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.
@@ -700,6 +755,7 @@ class CommandRegistrar:
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.
@@ -709,6 +765,8 @@ class CommandRegistrar:
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
@@ -740,6 +798,7 @@ class CommandRegistrar:
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
)
if registered:
results[agent_name] = registered
@@ -755,6 +814,7 @@ class CommandRegistrar:
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.
@@ -768,6 +828,8 @@ class CommandRegistrar:
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
@@ -795,6 +857,7 @@ class CommandRegistrar:
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
)
if registered:
results[agent_name] = registered
@@ -843,7 +906,7 @@ class CommandRegistrar:
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/

View File

@@ -823,6 +823,7 @@ class ExtensionManager:
self,
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
) -> List[str]:
"""Generate SKILL.md files for extension commands as agent skills.
@@ -834,6 +835,8 @@ class ExtensionManager:
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).
@@ -886,9 +889,18 @@ class ExtensionManager:
# 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.
@@ -940,11 +952,35 @@ class ExtensionManager:
skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
if link_outputs:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(skill_content, encoding="utf-8")
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, ValueError):
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
@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],
@@ -1115,6 +1151,7 @@ class ExtensionManager:
speckit_version: str,
register_commands: bool = True,
priority: int = 10,
link_commands: bool = False,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -1123,6 +1160,8 @@ class ExtensionManager:
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
@@ -1166,12 +1205,14 @@ class ExtensionManager:
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)
@@ -1607,7 +1648,8 @@ class CommandRegistrar:
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:
@@ -1615,20 +1657,23 @@ class CommandRegistrar:
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\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<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\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(
@@ -1643,10 +1688,13 @@ class CommandRegistrar:
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(CatalogStackBase):

View File

@@ -11,6 +11,7 @@ Tests cover:
"""
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
@@ -324,6 +337,149 @@ class TestExtensionSkillRegistration:
# 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_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
self, skills_project, extension_dir, monkeypatch
):
"""Dev-mode skill registration works when Windows cannot create symlinks."""
project_dir, skills_dir = skills_project
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
def raise_windows_symlink_error(target, link):
raise OSError("A required privilege is not held by the client")
monkeypatch.setattr(
"specify_cli.extensions.os.symlink", raise_windows_symlink_error
)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
).exists()
def test_dev_skill_registration_falls_back_to_copy_when_relpath_fails(
self, skills_project, extension_dir, monkeypatch
):
"""Dev-mode skill registration stays functional across Windows drive roots."""
project_dir, skills_dir = skills_project
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
def raise_relpath_error(path, start=None):
raise ValueError("path is on mount 'D:', start on mount 'C:'")
monkeypatch.setattr(
"specify_cli.extensions.os.path.relpath", raise_relpath_error
)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
).exists()
def test_dev_skill_registration_falls_back_to_copy_when_cache_write_fails(
self, skills_project, extension_dir, monkeypatch
):
"""Dev-mode skill registration stays functional when the dev cache is unwritable."""
project_dir, skills_dir = skills_project
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
original_write_text = Path.write_text
def raise_cache_write_error(path, *args, **kwargs):
if ".specify-dev" in path.parts:
raise OSError("cache is not writable")
return original_write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", raise_cache_write_error)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert not (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
).exists()
def test_registered_skills_in_registry(self, skills_project, extension_dir):
"""Registry should contain registered_skills list."""
project_dir, skills_dir = skills_project

View File

@@ -11,6 +11,7 @@ Tests cover:
import pytest
import json
import os
import platform
import tempfile
import shutil
@@ -36,6 +37,18 @@ from specify_cli.extensions import (
)
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,168 @@ Run {SCRIPT}
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_dev_register_commands_falls_back_to_copy_when_relpath_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Dev-mode registration stays functional across Windows drive roots."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
def raise_relpath_error(path, start=None):
raise ValueError("path is on mount 'D:', start on mount 'C:'")
monkeypatch.setattr("specify_cli.agents.os.path.relpath", raise_relpath_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_dev_register_commands_falls_back_to_copy_when_cache_write_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Dev-mode registration stays functional when the dev cache is unwritable."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
original_write_text = Path.write_text
def raise_cache_write_error(path, *args, **kwargs):
if ".specify-dev" in path.parts:
raise OSError("cache is not writable")
return original_write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", raise_cache_write_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 not (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "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"
@@ -3458,6 +3633,86 @@ class TestExtensionIgnore:
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_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
self, extension_dir, project_dir, monkeypatch
):
"""extension add --dev should work when Windows cannot create symlinks."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
(project_dir / ".github" / "agents").mkdir(parents=True)
def raise_windows_symlink_error(target, link):
raise OSError("A required privilege is not held by the client")
monkeypatch.setattr(
"specify_cli.agents.os.symlink", raise_windows_symlink_error
)
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()
assert not agent_file.is_symlink()
assert "Extension: test-ext" in agent_file.read_text(encoding="utf-8")
assert (
project_dir
/ ".specify"
/ "extensions"
/ "test-ext"
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "speckit.test-ext.hello.agent.md"
).exists()
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