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
81 changes: 80 additions & 1 deletion src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3924,14 +3924,91 @@ def safe_project_dir(relative: str) -> Optional[Path]:

return removed

def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
def invocation_outputs(
self, extension_id: str, agent_name: str
) -> List[tuple[str, Path]]:
"""Return ``(name, path)`` for each invocation output of ``agent_name``.

Checks the layout the agent currently uses on disk rather than
``registered_commands``, which some layouts leave empty: skill
directories in skills mode, otherwise every file command registration
writes (primary output, aliases, and Copilot's companion
``.prompt.md``), under the directory registration itself resolves
(home-relative and legacy directories included). Commands whose
source file is absent are skipped, as registration skips them too.

Raises:
ExtensionError: If the manifest is unreadable or the skills
directory cannot be resolved safely.
"""
import warnings

from .. import load_init_options
from ..agents import CommandRegistrar

manifest = self.get_extension(extension_id)
if manifest is None:
raise ExtensionError(f"Cannot read manifest for '{extension_id}'")
registrar = CommandRegistrar(self.project_root)
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
if not manifest.commands or not agent_config:
return []
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
opts = {}
skills_mode = (
opts.get("ai") == agent_name
and is_ai_skills_enabled(opts)
and agent_config.get("extension") != "/SKILL.md"
)
if skills_mode:
root = self._get_skills_dir(create=False)
if root is None:
raise ExtensionError(
f"Cannot resolve a usable skills directory for '{agent_name}'"
)
else:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
root = registrar._resolve_agent_dir(
agent_name, agent_config, self.project_root
)

ext_dir = self.extensions_dir / extension_id
outputs: List[tuple[str, Path]] = []
for cmd in manifest.commands:
if not (ext_dir / cmd["file"]).is_file():
continue
if skills_mode:
name = self._skill_name_for_command(cmd["name"])
outputs.append((name, root / name / "SKILL.md"))
continue
aliases = cmd.get("aliases") or []
for invoked in [cmd["name"], *[a for a in aliases if isinstance(a, str)]]:
name = registrar._compute_output_name(agent_name, invoked, agent_config)
outputs.append((name, root / f"{name}{agent_config['extension']}"))
if agent_name == "copilot":
prompt = self.project_root / ".github" / "prompts" / f"{invoked}.prompt.md"
outputs.append((f"{invoked}.prompt.md", prompt))

return outputs

def register_enabled_extensions_for_agent(
self,
agent_name: str,
*,
force: bool = False,
extension_ids: Optional[Set[str]] = None,
) -> None:
"""Register installed, enabled extensions for ``agent_name``.

Command-file registration is scoped to the explicit ``agent_name``
argument. Since #2948, callers pass the active agent only (``use`` /
``switch`` activate the target first; ``upgrade`` calls it only for
the active integration), so extension skill rendering — scoped to the
active ``ai`` / ``ai_skills`` init-options — matches ``agent_name``.

``extension_ids``, when given, limits registration to those extensions.
"""
if not agent_name:
return
Expand Down Expand Up @@ -3970,6 +4047,8 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name)

for ext_id, metadata in self.registry.list().items():
if extension_ids is not None and ext_id not in extension_ids:
continue
if not metadata.get("enabled", True):
continue

Expand Down
55 changes: 55 additions & 0 deletions src/specify_cli/extensions/command_enable.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,42 @@
from . import _commands


def _present(path) -> bool:
return path.exists() or path.is_symlink()


def _register_if_absent(manager, extension_id: str, agent: str, metadata: dict) -> None:
"""Register ``extension_id`` for ``agent`` when none of its outputs exist.

Surviving outputs (customized files, dev symlinks) are never rewritten.
Every output was absent before registration, so if it leaves any missing
or raises, the outputs it did write and its registry tracking are undone,
and a retry starts from the same clean state.
"""
from . import ExtensionError

outputs = manager.invocation_outputs(extension_id, agent)
if not outputs or any(_present(path) for _, path in outputs):
return
try:
manager.register_enabled_extensions_for_agent(agent, extension_ids={extension_id})
missing = sorted(name for name, path in outputs if not _present(path))
if missing:
raise ExtensionError("Missing invocation artifacts: " + ", ".join(missing))
Comment on lines +33 to +36
except Exception:
for _, path in outputs:
if path.is_symlink() or path.is_file():
path.unlink()
Comment on lines +38 to +40
if path.name == "SKILL.md" and path.parent.is_dir() and not any(path.parent.iterdir()):
path.parent.rmdir()
manager.registry.update(extension_id, {
key: metadata[key]
for key in ("registered_commands", "registered_skills")
if key in metadata
})
raise


@_commands.extension_app.command("enable")
def extension_enable(
extension: str = typer.Argument(help="Extension ID or name to enable"),
Expand Down Expand Up @@ -81,6 +117,25 @@ def extension_enable(
f"for '{_escape_markup(str(extension_id))}': {_escape_markup(str(exc))}"
)
raise typer.Exit(1) from exc
else:
# `integration use`/`switch` register only enabled extensions for the
# agent they activate, and `switch` removes a disabled extension's
# commands from the old one, so an extension disabled at that point has
# no commands for the now-active agent. Register them only when none
# of the extension's outputs exist for the active layout, so surviving
# files, edits and dev symlinks are never rewritten, and roll back if
# registration leaves any output missing.
active_ai = init_options.get("ai")
if isinstance(active_ai, str) and active_ai:
try:
_register_if_absent(manager, extension_id, active_ai, metadata)
except Exception as exc:
manager.registry.update(extension_id, {"enabled": False})
console.print(
f"[red]Error:[/red] Could not register invocations "
f"for '{_escape_markup(str(extension_id))}': {_escape_markup(str(exc))}"
)
raise typer.Exit(1) from exc

# Enable hooks in extensions.yml
config = hook_executor.get_project_config()
Expand Down
Loading
Loading