Skip to content

Commit 9307093

Browse files
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
1 parent 5a678c5 commit 9307093

5 files changed

Lines changed: 543 additions & 16 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2971,7 +2971,12 @@ def extension_add(
29712971
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
29722972
raise typer.Exit(1)
29732973

2974-
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
2974+
manifest = manager.install_from_directory(
2975+
source_path,
2976+
speckit_version,
2977+
priority=priority,
2978+
link_commands=True,
2979+
)
29752980

29762981
elif from_url:
29772982
# Install from URL (ZIP file)

src/specify_cli/agents.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ def register_commands(
439439
project_root: Path,
440440
context_note: str = None,
441441
_resolved_dir: Path = None,
442+
link_outputs: bool = False,
442443
) -> List[str]:
443444
"""Register commands for a specific agent.
444445
@@ -453,6 +454,9 @@ def register_commands(
453454
only — avoids a second ``_resolve_agent_dir`` call and
454455
duplicate deprecation warnings when invoked from
455456
``register_commands_for_all_agents``).
457+
link_outputs: If True, write rendered output to a source-local
458+
dev cache and symlink the agent command file to it. Falls back
459+
to a normal file write when symlinks are unavailable.
456460
457461
Returns:
458462
List of registered command names
@@ -559,7 +563,15 @@ def register_commands(
559563
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
560564
self._ensure_inside(dest_file, commands_dir)
561565
dest_file.parent.mkdir(parents=True, exist_ok=True)
562-
dest_file.write_text(output, encoding="utf-8")
566+
self._write_registered_output(
567+
dest_file,
568+
output,
569+
source_dir,
570+
agent_name,
571+
output_name,
572+
agent_config["extension"],
573+
link_outputs,
574+
)
563575

564576
if agent_name == "copilot":
565577
self.write_copilot_prompt(project_root, cmd_name)
@@ -625,13 +637,56 @@ def register_commands(
625637
)
626638
self._ensure_inside(alias_file, commands_dir)
627639
alias_file.parent.mkdir(parents=True, exist_ok=True)
628-
alias_file.write_text(alias_output, encoding="utf-8")
640+
self._write_registered_output(
641+
alias_file,
642+
alias_output,
643+
source_dir,
644+
agent_name,
645+
alias_output_name,
646+
agent_config["extension"],
647+
link_outputs,
648+
)
629649
if agent_name == "copilot":
630650
self.write_copilot_prompt(project_root, alias)
631651
registered.append(alias)
632652

633653
return registered
634654

655+
@staticmethod
656+
def _write_registered_output(
657+
dest_file: Path,
658+
content: str,
659+
source_dir: Path,
660+
agent_name: str,
661+
output_name: str,
662+
extension: str,
663+
link_outputs: bool,
664+
) -> None:
665+
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
666+
if not link_outputs:
667+
dest_file.write_text(content, encoding="utf-8")
668+
return
669+
670+
rel_output = Path(f"{output_name}{extension}")
671+
cache_root = source_dir / ".specify-dev" / "agent-commands" / agent_name
672+
cache_file = cache_root / rel_output
673+
CommandRegistrar._ensure_inside(cache_file, cache_root)
674+
675+
try:
676+
cache_file.parent.mkdir(parents=True, exist_ok=True)
677+
cache_file.write_text(content, encoding="utf-8")
678+
if dest_file.exists() or dest_file.is_symlink():
679+
dest_file.unlink()
680+
target = os.path.relpath(cache_file, dest_file.parent)
681+
os.symlink(target, dest_file)
682+
except (OSError, ValueError):
683+
# Windows often requires Developer Mode or admin privileges for
684+
# symlinks, and relpath can fail across drives. Keep dev installs
685+
# functional by falling back to a copy.
686+
if dest_file.is_symlink():
687+
dest_file.unlink()
688+
dest_file.write_text(content, encoding="utf-8")
689+
635690
@staticmethod
636691
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
637692
"""Generate a companion .prompt.md file for a Copilot agent command.
@@ -700,6 +755,7 @@ def register_commands_for_all_agents(
700755
source_dir: Path,
701756
project_root: Path,
702757
context_note: str = None,
758+
link_outputs: bool = False,
703759
) -> Dict[str, List[str]]:
704760
"""Register commands for all detected agents in the project.
705761
@@ -709,6 +765,8 @@ def register_commands_for_all_agents(
709765
source_dir: Directory containing command source files
710766
project_root: Path to project root
711767
context_note: Custom context comment for markdown output
768+
link_outputs: If True, create dev-mode symlinks for rendered
769+
command files when supported by the OS.
712770
713771
Returns:
714772
Dictionary mapping agent names to list of registered commands
@@ -740,6 +798,7 @@ def register_commands_for_all_agents(
740798
project_root,
741799
context_note=context_note,
742800
_resolved_dir=agent_dir,
801+
link_outputs=link_outputs,
743802
)
744803
if registered:
745804
results[agent_name] = registered
@@ -755,6 +814,7 @@ def register_commands_for_non_skill_agents(
755814
source_dir: Path,
756815
project_root: Path,
757816
context_note: Optional[str] = None,
817+
link_outputs: bool = False,
758818
) -> Dict[str, List[str]]:
759819
"""Register commands for all non-skill agents in the project.
760820
@@ -768,6 +828,8 @@ def register_commands_for_non_skill_agents(
768828
source_dir: Directory containing command source files
769829
project_root: Path to project root
770830
context_note: Custom context comment for markdown output
831+
link_outputs: If True, create dev-mode symlinks for rendered
832+
command files when supported by the OS.
771833
772834
Returns:
773835
Dictionary mapping agent names to list of registered commands
@@ -795,6 +857,7 @@ def register_commands_for_non_skill_agents(
795857
project_root,
796858
context_note=context_note,
797859
_resolved_dir=agent_dir,
860+
link_outputs=link_outputs,
798861
)
799862
if registered:
800863
results[agent_name] = registered
@@ -843,7 +906,7 @@ def unregister_commands(
843906
cmd_file = (
844907
target_dir / f"{output_name}{agent_config['extension']}"
845908
)
846-
if cmd_file.exists():
909+
if cmd_file.exists() or cmd_file.is_symlink():
847910
cmd_file.unlink()
848911
# For SKILL.md agents each command lives in its own
849912
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/

src/specify_cli/extensions.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,7 @@ def _register_extension_skills(
823823
self,
824824
manifest: ExtensionManifest,
825825
extension_dir: Path,
826+
link_outputs: bool = False,
826827
) -> List[str]:
827828
"""Generate SKILL.md files for extension commands as agent skills.
828829
@@ -834,6 +835,8 @@ def _register_extension_skills(
834835
Args:
835836
manifest: Extension manifest.
836837
extension_dir: Installed extension directory.
838+
link_outputs: If True, create dev-mode symlinks for rendered
839+
skill files when supported by the OS.
837840
838841
Returns:
839842
List of skill names that were created (for registry storage).
@@ -886,9 +889,18 @@ def _register_extension_skills(
886889
# Check if skill already exists before creating the directory
887890
skill_subdir = skills_dir / skill_name
888891
skill_file = skill_subdir / "SKILL.md"
889-
if skill_file.exists():
890-
# Do not overwrite user-customized skills
891-
continue
892+
cache_root = extension_dir / ".specify-dev" / "extension-skills"
893+
cache_file = cache_root / skill_name / "SKILL.md"
894+
CommandRegistrar._ensure_inside(cache_file, cache_root)
895+
if skill_file.exists() or skill_file.is_symlink():
896+
# Do not overwrite user-customized skills, but allow dev-mode
897+
# symlinks that point back to this extension's generated cache
898+
# to be refreshed on a subsequent dev install.
899+
if not (
900+
link_outputs
901+
and self._is_expected_dev_symlink(skill_file, cache_file)
902+
):
903+
continue
892904

893905
# Create skill directory; track whether we created it so we can clean
894906
# up safely if reading the source file subsequently fails.
@@ -940,11 +952,35 @@ def _register_extension_skills(
940952
skill_content
941953
)
942954

943-
skill_file.write_text(skill_content, encoding="utf-8")
955+
if link_outputs:
956+
try:
957+
cache_file.parent.mkdir(parents=True, exist_ok=True)
958+
cache_file.write_text(skill_content, encoding="utf-8")
959+
if skill_file.exists() or skill_file.is_symlink():
960+
skill_file.unlink()
961+
target = os.path.relpath(cache_file, skill_file.parent)
962+
os.symlink(target, skill_file)
963+
except (OSError, ValueError):
964+
if skill_file.is_symlink():
965+
skill_file.unlink()
966+
skill_file.write_text(skill_content, encoding="utf-8")
967+
else:
968+
skill_file.write_text(skill_content, encoding="utf-8")
944969
written.append(skill_name)
945970

946971
return written
947972

973+
@staticmethod
974+
def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
975+
"""Return True when an existing skill file links to its dev cache."""
976+
if not skill_file.is_symlink():
977+
return False
978+
979+
try:
980+
return skill_file.resolve(strict=False) == cache_file.resolve(strict=False)
981+
except OSError:
982+
return False
983+
948984
def _unregister_extension_skills(
949985
self,
950986
skill_names: List[str],
@@ -1115,6 +1151,7 @@ def install_from_directory(
11151151
speckit_version: str,
11161152
register_commands: bool = True,
11171153
priority: int = 10,
1154+
link_commands: bool = False,
11181155
) -> ExtensionManifest:
11191156
"""Install extension from a local directory.
11201157
@@ -1123,6 +1160,8 @@ def install_from_directory(
11231160
speckit_version: Current spec-kit version
11241161
register_commands: If True, register commands with AI agents
11251162
priority: Resolution priority (lower = higher precedence, default 10)
1163+
link_commands: If True, register rendered agent artifacts as
1164+
symlinks to a dev cache when supported by the OS.
11261165
11271166
Returns:
11281167
Installed extension manifest
@@ -1166,12 +1205,14 @@ def install_from_directory(
11661205
registrar = CommandRegistrar()
11671206
# Register for all detected agents
11681207
registered_commands = registrar.register_commands_for_all_agents(
1169-
manifest, dest_dir, self.project_root
1208+
manifest, dest_dir, self.project_root, link_outputs=link_commands
11701209
)
11711210

11721211
# Auto-register extension commands as agent skills when --ai-skills
11731212
# was used during project initialisation (feature parity).
1174-
registered_skills = self._register_extension_skills(manifest, dest_dir)
1213+
registered_skills = self._register_extension_skills(
1214+
manifest, dest_dir, link_outputs=link_commands
1215+
)
11751216

11761217
# Register hooks and update installed list in extensions.yml
11771218
hook_executor = HookExecutor(self.project_root)
@@ -1607,28 +1648,32 @@ def register_commands_for_agent(
16071648
agent_name: str,
16081649
manifest: ExtensionManifest,
16091650
extension_dir: Path,
1610-
project_root: Path
1651+
project_root: Path,
1652+
link_outputs: bool = False,
16111653
) -> List[str]:
16121654
"""Register extension commands for a specific agent."""
16131655
if agent_name not in self.AGENT_CONFIGS:
16141656
raise ExtensionError(f"Unsupported agent: {agent_name}")
16151657
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
16161658
return self._registrar.register_commands(
16171659
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
1618-
context_note=context_note
1660+
context_note=context_note,
1661+
link_outputs=link_outputs,
16191662
)
16201663

16211664
def register_commands_for_all_agents(
16221665
self,
16231666
manifest: ExtensionManifest,
16241667
extension_dir: Path,
1625-
project_root: Path
1668+
project_root: Path,
1669+
link_outputs: bool = False,
16261670
) -> Dict[str, List[str]]:
16271671
"""Register extension commands for all detected agents."""
16281672
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
16291673
return self._registrar.register_commands_for_all_agents(
16301674
manifest.commands, manifest.id, extension_dir, project_root,
1631-
context_note=context_note
1675+
context_note=context_note,
1676+
link_outputs=link_outputs,
16321677
)
16331678

16341679
def unregister_commands(
@@ -1643,10 +1688,13 @@ def register_commands_for_claude(
16431688
self,
16441689
manifest: ExtensionManifest,
16451690
extension_dir: Path,
1646-
project_root: Path
1691+
project_root: Path,
1692+
link_outputs: bool = False,
16471693
) -> List[str]:
16481694
"""Register extension commands for Claude Code agent."""
1649-
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
1695+
return self.register_commands_for_agent(
1696+
"claude", manifest, extension_dir, project_root, link_outputs=link_outputs
1697+
)
16501698

16511699

16521700
class ExtensionCatalog(CatalogStackBase):

0 commit comments

Comments
 (0)