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):