Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 65 additions & 3 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -625,13 +637,55 @@ 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_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")

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.
Comment on lines +682 to +684
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.
Expand Down Expand Up @@ -687,6 +741,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.

Expand All @@ -696,6 +751,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
Expand All @@ -718,6 +775,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
Expand All @@ -733,6 +791,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.

Expand All @@ -746,6 +805,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
Expand All @@ -768,6 +829,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
Expand Down Expand Up @@ -816,7 +878,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/
Expand Down
72 changes: 60 additions & 12 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
Expand Down Expand Up @@ -903,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.
Expand Down Expand Up @@ -957,11 +969,35 @@ def _register_extension_skills(
skill_content
)

skill_file.write_text(skill_content, encoding="utf-8")
if link_outputs:
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:
Comment thread
NgoQuocViet2001 marked this conversation as resolved.
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],
Expand Down Expand Up @@ -1132,6 +1168,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.

Expand All @@ -1140,6 +1177,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
Expand Down Expand Up @@ -1183,12 +1222,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)
Expand Down Expand Up @@ -1624,28 +1665,32 @@ 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:
raise ExtensionError(f"Unsupported agent: {agent_name}")
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(
Expand All @@ -1660,10 +1705,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:
Expand Down
Loading
Loading