mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user