Files
spec-kit/src/specify_cli/extensions/_commands.py
T
15cb7d9a66 feat(extensions): scaffold config templates on extension add/enable (#2000)
* feat(extensions): scaffold config templates on extension add/enable

Deploy an extension's provides.config templates into .specify/ when the
extension is added or enabled. Existing files are never overwritten, so
user customizations are preserved.

Addresses the review on #2000:
- ExtensionManifest.config returns [] unless provides.config is a list of
  dicts, so a malformed manifest cannot crash callers.
- scaffold_config returns a consistent (deployed, skipped_existing, failed)
  tuple on every path, including a missing manifest.
- Template paths must resolve inside the extension dir and targets inside
  .specify/; symlinks and non-regular files are rejected.
- Callers distinguish "already exists (preserved)" from "not scaffolded",
  and extension_enable no longer crashes on a corrupt manifest.
- Tests cover traversal, absolute paths, symlinks, directory templates,
  malformed provides.config, and the missing-manifest tuple shape.

Ported onto the extensions package introduced by #3014: the manager and
manifest changes land in extensions/__init__.py and the CLI wiring in
extensions/_commands.py.

* fix(extensions): deploy config where it is read, and contain the write

Addresses @Copilot's review.

Config now lands in .specify/extensions/<id>/ rather than the .specify/
root. ConfigManager._get_project_config() reads
.specify/extensions/<id>/<id>-config.yml, and the bundled scripts and
READMEs use the same path, so a scaffolded git-config.yml was being
written somewhere the git extension never looks.

Containment is checked component by component before .specify is used as
the root. Resolving it first and trusting the result let a symlinked
component point outside the project, after which every target satisfied
relative_to and copy2 wrote externally. This matches the project
safe-write path in shared_infra.

mkdir moved inside the OSError handler. A nested target like
foo/config.yml raised out of scaffolding when its parent could not be
created, and on extension add that happened after the extension was
already installed.

The 'Configuration may be required' warning is now conditional. It ran
unconditionally after the scaffolding block, so it contradicted the
success output directly above it and fired for extensions with no
provides.config at all.

Tests cover the corrected location, a symlinked config root, and an
uncreatable nested target.

* fix(extensions): only scaffold config targets that removal preserves

remove(keep_config=True) rmtree's every subdirectory and keeps only top-level
-config.yml / -config.local.yml files; the backup path globs the same
top-level pattern. Scaffolding a nested or differently-named target therefore
handed the user a file that 'extension add --force' silently deleted and
replaced with the template default, losing customization.

Constrain scaffold targets to that convention rather than widening four
removal paths.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:24:21 -05:00

2705 lines
122 KiB
Python

"""specify extension * and catalog * command handlers — app objects and register().
Moved out of __init__.py (PR-7/8). Handlers reference helpers that remain in
the package root (`_require_specify_project`, `_locate_bundled_extension`,
`load_init_options`, `_display_project_path`) through the thin shims below,
which re-fetch from the parent package at call time so test monkeypatching of
`specify_cli.<helper>` keeps working.
"""
from __future__ import annotations
import errno
import hashlib
import os
import shutil
import stat
import tempfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
import typer
import yaml
from rich.markup import escape as _escape_markup
from rich.panel import Panel
from rich.table import Table
from .._console import console
from .._assets import get_speckit_version
from .._download_security import (
archive_format_from_name,
detect_archive_format,
is_https_or_localhost_http,
read_response_limited,
safe_extract_archive,
)
from .._init_options import is_ai_skills_enabled
extension_app = typer.Typer(
name="extension",
help="Manage spec-kit extensions",
add_completion=False,
)
catalog_app = typer.Typer(
name="catalog",
help="Manage extension catalogs",
add_completion=False,
)
extension_app.add_typer(catalog_app, name="catalog")
# Root helpers re-fetched at call time so test monkeypatching of
# `specify_cli.<name>` keeps working after the move.
def _require_specify_project(*args, **kwargs):
from .. import _require_specify_project as _f
return _f(*args, **kwargs)
def _locate_bundled_extension(*args, **kwargs):
from .. import _locate_bundled_extension as _f
return _f(*args, **kwargs)
def load_init_options(*args, **kwargs):
from .. import load_init_options as _f
return _f(*args, **kwargs)
def _display_project_path(*args, **kwargs):
from .. import _display_project_path as _f
return _f(*args, **kwargs)
def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).
The extension has already been added/removed/enabled/disabled by the time
this runs, so a refresh failure must not abort the command — but it must
be surfaced, because a stale native hook may still be active (e.g. a
disabled extension's hook still resolves and runs). Prints a warning with
the per-integration failures so the user knows deactivation was incomplete.
"""
from ..events import EventRefreshError, refresh_integration_events
try:
refresh_integration_events(project_root)
except EventRefreshError as exc:
console.print(
f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed "
f"for {len(exc.failures)} integration(s); a stale native hook may "
f"still be active. Re-run [cyan]specify integration upgrade "
f"<key>[cyan][/cyan][/cyan] to retry."
)
for key, detail in exc.failures:
console.print(f" {key}: {_escape_markup(detail)}")
def install_extension_from_url(
manager,
project_root: Path,
url: str,
speckit_version: str,
*,
priority: int = 10,
force: bool = False,
):
"""Download an archive from *url* and install it, reusing the hardened path.
Shares the same download hardening as ``extension add --from``:
HTTPS enforcement, the catalog's authenticated + redirect-guarded
``_open_url`` fetch, a bounded (50 MiB) response read, archive-format
detection (ZIP or tar.gz/tgz), and a TOCTOU-safe transient download file
consumed directly by ``install_from_zip``.
Returns the installed manifest. Raises ``ExtensionError`` on any failure so
callers can present a uniform message without a second downloader.
"""
import urllib.error
from . import ExtensionCatalog, ExtensionError
if not is_https_or_localhost_http(url):
raise ExtensionError(
"URL must use HTTPS (HTTP is only allowed for localhost)"
)
download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
# Only used for diagnostic messages: the real archive is a transient inode
# (unlinked on POSIX, O_TEMPORARY on Windows) consumed via ``archive_file``
# below, so this path is never opened again.
archive_path = download_dir / archive_filename
try:
dl_catalog = ExtensionCatalog(project_root)
download_url = url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {url}",
)
final_url = (
response.geturl() if hasattr(response, "geturl") else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
except urllib.error.URLError as exc:
raise ExtensionError(f"Failed to download from {url}: {exc}") from exc
download_fd = -1
download_file = None
try:
try:
download_fd = _safe_open_download_zip(
project_root, download_dir, archive_filename
)
except OSError as exc:
raise ExtensionError(
f"Could not safely create download file: {exc}"
) from exc
try:
download_file = os.fdopen(download_fd, "w+b")
download_fd = -1
download_file.write(archive_data)
download_file.flush()
download_file.seek(0)
except OSError as exc:
raise ExtensionError(
f"Could not safely write download file: {exc}"
) from exc
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else url
)
try:
detect_archive_format(
archive_path,
archive_file=download_file,
source_name=format_source,
content_type=content_type,
error_type=ExtensionError,
)
except ExtensionError as exc:
raise ExtensionError(
f"{url} did not return a ZIP archive or tar.gz/tgz archive "
f"(got {len(archive_data)} bytes). This usually means the request "
"was not authenticated and a login/HTML page was returned. "
"Verify the URL and configured credentials."
) from exc
# Consume the transient inode reserved above rather than reopening the
# cache pathname during extraction.
try:
return manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
archive_file=download_file,
)
except OSError as exc:
raise ExtensionError(
f"Could not install extension from downloaded archive: {exc}"
) from exc
finally:
if download_file is not None:
try:
download_file.close()
except OSError:
pass
elif download_fd >= 0:
try:
os.close(download_fd)
except OSError:
pass
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
"""Load extension catalog CLI config with user-facing shape errors."""
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except Exception as e:
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"[red]Error:[/red] Failed to read {config_label}: {_escape_markup(str(e))}")
raise typer.Exit(1)
if config is None:
return {}
if not isinstance(config, dict):
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(
f"[red]Error:[/red] Invalid catalog config {config_label}: "
"expected a YAML mapping at the root."
)
raise typer.Exit(1)
return config
def _resolve_installed_extension(
argument: str,
installed_extensions: list,
command_name: str = "command",
allow_not_found: bool = False,
) -> tuple[Optional[str], Optional[str]]:
"""Resolve an extension argument (ID or display name) to an installed extension.
Args:
argument: Extension ID or display name provided by user
installed_extensions: List of installed extension dicts from manager.list_installed()
command_name: Name of the command for error messages (e.g., "enable", "disable")
allow_not_found: If True, return (None, None) when not found instead of raising
Returns:
Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
Raises:
typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
"""
# First, try exact ID match
for ext in installed_extensions:
if ext["id"] == argument:
return (ext["id"], ext["name"])
# If not found by ID, try display name match
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
if len(name_matches) == 1:
# Unique display-name match
return (name_matches[0]["id"], name_matches[0]["name"])
elif len(name_matches) > 1:
# Ambiguous display-name match
console.print(
f"[red]Error:[/red] Extension name '{_escape_markup(argument)}' is ambiguous. "
"Multiple installed extensions share this name:"
)
table = Table(title="Matching extensions")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Name", style="white")
table.add_column("Version", style="green")
for ext in name_matches:
table.add_row(
_escape_markup(str(ext.get("id", ""))),
_escape_markup(str(ext.get("name", ""))),
_escape_markup(str(ext.get("version", ""))),
)
console.print(table)
console.print("\nPlease rerun using the extension ID:")
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
raise typer.Exit(1)
else:
# No match by ID or display name
if allow_not_found:
return (None, None)
console.print(f"[red]Error:[/red] Extension '{_escape_markup(argument)}' is not installed")
raise typer.Exit(1)
def _resolve_catalog_extension(
argument: str,
catalog,
command_name: str = "info",
) -> tuple[Optional[dict], Optional[Exception]]:
"""Resolve an extension argument (ID or display name) from the catalog.
Args:
argument: Extension ID or display name provided by user
catalog: ExtensionCatalog instance
command_name: Name of the command for error messages
Returns:
Tuple of (extension_info, catalog_error)
- If found: (ext_info_dict, None)
- If catalog error: (None, error)
- If not found: (None, None)
"""
from . import ExtensionError
try:
# First try by ID
ext_info = catalog.get_extension_info(argument)
if ext_info:
return (ext_info, None)
# Try by display name - search using argument as query, then filter for exact match.
# Coerce name defensively: catalog JSON is user-editable, so a hand-authored
# non-string/missing name must not crash the match (the ambiguous-match display
# below already str()-coerces name for the same reason).
search_results = catalog.search()
argument_lower = argument.lower()
name_matches = [
ext
for ext in search_results
if str(ext.get("name", "")).lower() == argument_lower
]
if len(name_matches) == 1:
return (name_matches[0], None)
elif len(name_matches) > 1:
# Ambiguous display-name match in catalog
console.print(
f"[red]Error:[/red] Extension name '{_escape_markup(argument)}' is ambiguous. "
"Multiple catalog extensions share this name:"
)
table = Table(title="Matching extensions")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Name", style="white")
table.add_column("Version", style="green")
table.add_column("Catalog", style="dim")
for ext in name_matches:
table.add_row(
_escape_markup(str(ext.get("id", ""))),
_escape_markup(str(ext.get("name", ""))),
_escape_markup(str(ext.get("version", ""))),
_escape_markup(str(ext.get("_catalog_name", ""))),
)
console.print(table)
console.print("\nPlease rerun using the extension ID:")
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
raise typer.Exit(1)
# Not found
return (None, None)
except ExtensionError as e:
return (None, e)
@extension_app.command("list")
def extension_list(
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
):
"""List installed extensions."""
from . import ExtensionManager
project_root = _require_specify_project()
manager = ExtensionManager(project_root)
installed = manager.list_installed()
if not installed and not (available or all_extensions):
console.print("[yellow]No extensions installed.[/yellow]")
console.print("\nInstall an extension with:")
console.print(" specify extension add <extension-name>")
return
if installed:
console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")
for ext in installed:
status_icon = "✓" if ext["enabled"] else "✗"
status_color = "green" if ext["enabled"] else "red"
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{_escape_markup(ext['name'])}[/bold] (v{_escape_markup(str(ext['version']))})")
console.print(f" [dim]{_escape_markup(ext['id'])}[/dim]")
console.print(f" {_escape_markup(ext['description'])}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()
if available or all_extensions:
console.print("\nInstall an extension:")
console.print(" [cyan]specify extension add <name>[/cyan]")
@catalog_app.command("list")
def catalog_list():
"""List all active extension catalogs."""
from . import ExtensionCatalog, ValidationError
project_root = _require_specify_project()
catalog = ExtensionCatalog(project_root)
try:
active_catalogs = catalog.get_active_catalogs()
except ValidationError as e:
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{_escape_markup(entry.name)}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {_escape_markup(entry.description)}")
console.print(f" URL: {_escape_markup(str(entry.url))}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "extension-catalogs.yml"
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
if os.environ.get("SPECKIT_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
else:
try:
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
except ValidationError:
proj_loaded = False
if proj_loaded:
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"[dim]Config: {config_label}[/dim]")
else:
try:
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
except ValidationError:
user_loaded = False
if user_loaded:
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
)
@catalog_app.command("add")
def catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed",
),
description: str = typer.Option("", "--description", help="Description of the catalog"),
):
"""Add a catalog to .specify/extension-catalogs.yml."""
from . import ExtensionCatalog, ValidationError
project_root = _require_specify_project()
specify_dir = project_root / ".specify"
# Validate URL
tmp_catalog = ExtensionCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except ValidationError as e:
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
config_path = specify_dir / "extension-catalogs.yml"
# Load existing config
if config_path.exists():
config = _load_catalog_command_config(project_root, config_path)
else:
config = {}
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
safe_name = _escape_markup(name)
safe_url = _escape_markup(url)
# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1)
catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
})
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})")
console.print(f" URL: {safe_url}")
console.print(f" Priority: {priority}")
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"\nConfig saved to {config_label}")
@catalog_app.command("remove")
def catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/extension-catalogs.yml."""
project_root = _require_specify_project()
specify_dir = project_root / ".specify"
config_path = specify_dir / "extension-catalogs.yml"
if not config_path.exists():
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
raise typer.Exit(1)
config = _load_catalog_command_config(project_root, config_path)
catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
safe_name = _escape_markup(name)
original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
# Relative path, below the project root, of the extension URL download cache.
_CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads")
def _has_secure_dir_fd() -> bool:
"""Whether this platform supports the strongest (POSIX) hardening path.
The descriptor-anchored walk needs ``O_NOFOLLOW`` plus ``dir_fd`` support
for ``os.open``/``os.mkdir``/``os.unlink``. When any of those is missing
(notably on Windows) the caller falls back to the portable path-wise walk,
which reproduces the same guarantees using symlink/reparse-point rejection,
resolve-under-root containment checks, and post-open inode-identity
verification instead of file descriptors.
"""
return bool(
getattr(os, "O_NOFOLLOW", 0)
and os.open in os.supports_dir_fd
and os.mkdir in os.supports_dir_fd
and os.unlink in os.supports_dir_fd
)
def _is_symlink_refusal_errno(exc: OSError) -> bool:
"""Whether an ``os.open``/``os.mkdir`` error means a component is a symlink.
Opening an ``O_NOFOLLOW`` path whose final component is a symlink raises
``ELOOP`` on Linux and ``EMLINK`` on some BSDs, while a symlinked component
that no longer resolves to a directory surfaces as ``ENOTDIR``.
"""
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
def _verify_leaf_identity(fd: int, path: Path) -> None:
"""Confirm ``fd`` still refers to the regular file at ``path``.
Mirrors the workflow installer's staged-file check: comparing the open
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
that was swapped for a symlink/reparse point between creation and use, so
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
"""
path_stat = path.stat(follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError(
errno.ENOTDIR, "Download file changed between creation and open"
)
def _validate_safe_cache_dir(project_root: Path) -> Path:
"""Create and validate the extension URL download cache one component at a
time, refusing symlinked/junctioned components on every supported platform."""
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
try:
if _has_secure_dir_fd():
_validate_cache_dir_via_dir_fd(project_root, download_dir)
else:
_validate_cache_dir_via_paths(project_root, download_dir)
except typer.Exit:
raise
except FileExistsError:
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
except OSError as exc:
if _is_symlink_refusal_errno(exc):
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
console.print(
"[red]Error:[/red] Could not prepare download cache directory: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
return download_dir
def _validate_cache_dir_via_dir_fd(project_root: Path, download_dir: Path) -> None:
"""POSIX cache-dir walk anchored on ``dir_fd`` + ``O_NOFOLLOW`` descriptors."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
project_root_resolved = project_root.resolve()
parent_fd = os.open(project_root, walk_flags)
current_path = project_root
try:
for part in _CACHE_REL_PARTS:
current_path = current_path / part
try:
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
except FileNotFoundError:
try:
os.mkdir(part, dir_fd=parent_fd)
except FileExistsError:
pass
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
try:
os.close(child_fd)
except OSError:
pass
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
os.close(parent_fd)
parent_fd = child_fd
finally:
if parent_fd >= 0:
try:
os.close(parent_fd)
except OSError:
pass
def _validate_cache_dir_via_paths(project_root: Path, download_dir: Path) -> None:
"""Portable cache-dir walk for platforms without ``dir_fd`` (e.g. Windows).
Each component is created individually while a symlink/junction is rejected
both before and after creation, and every component is required to resolve
back under the project root so a mount-point alias or reparse point cannot
redirect the cache outside the project.
"""
project_root_resolved = project_root.resolve()
current_path = project_root
for part in _CACHE_REL_PARTS:
current_path = current_path / part
if current_path.is_symlink():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.mkdir()
except FileExistsError:
pass
# Re-check after creation: a component swapped for a symlink/junction
# (or an existing non-directory) between the check and mkdir is caught
# here before the walk descends into it.
if current_path.is_symlink() or not current_path.is_dir():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
def _safe_open_download_zip(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Exclusively create a download ZIP and return an owned descriptor.
The archive never persists as a nameable on-disk file: the POSIX path
unlinks the leaf immediately after exclusive creation (anonymous inode),
while the portable path opens it with ``O_TEMPORARY`` so the OS deletes it
when the last handle closes. Installation proceeds entirely through the
returned descriptor, removing the pathname-reopen and cleanup-walk TOCTOU
classes on every supported platform.
"""
if _has_secure_dir_fd():
return _open_download_zip_via_dir_fd(
project_root, download_dir, zip_filename
)
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)
def _open_download_zip_via_dir_fd(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""POSIX leaf create: descriptor walk, ``O_EXCL`` create, immediate unlink."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
rel_parts = download_dir.relative_to(project_root).parts
parent_fd = os.open(project_root, walk_flags)
try:
for part in rel_parts:
new_fd = os.open(part, walk_flags, dir_fd=parent_fd)
os.close(parent_fd)
parent_fd = new_fd
download_fd = os.open(
zip_filename,
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_nofollow | o_cloexec,
0o600,
dir_fd=parent_fd,
)
try:
os.unlink(zip_filename, dir_fd=parent_fd)
except OSError:
os.close(download_fd)
raise
return download_fd
finally:
os.close(parent_fd)
def _open_download_zip_via_paths(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Portable leaf create for platforms without ``dir_fd`` (e.g. Windows).
The cache directory is re-validated (real directory, under the project
root) immediately before an exclusive create. ``O_EXCL`` guarantees an
attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
makes the OS delete it on close, and a post-open inode-identity check
detects a leaf swapped underneath us. The returned descriptor is the only
handle installation ever uses, so the cache pathname is never reopened.
"""
zip_path = download_dir / zip_filename
project_root_resolved = project_root.resolve()
if download_dir.is_symlink() or not download_dir.is_dir():
raise OSError(
errno.ENOTDIR, "Download cache directory is not a real directory"
)
try:
download_dir.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
if zip_path.is_symlink():
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_BINARY", 0)
o_temporary = getattr(os, "O_TEMPORARY", 0)
flags |= o_temporary
download_fd = os.open(zip_path, flags, 0o600)
try:
_verify_leaf_identity(download_fd, zip_path)
except OSError:
os.close(download_fd)
# Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
# we just exclusively created (best effort, never through a symlink).
if not o_temporary:
try:
if not zip_path.is_symlink():
zip_path.unlink()
except OSError:
pass
raise
return download_fd
@extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from . import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
if force:
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
# Prompt for URL-based installs BEFORE the spinner so the user can
# actually see and respond to the confirmation (the Rich status
# spinner overwrites the typer.confirm prompt line, making it appear
# as though the command is hung).
# Guard with ``not dev`` so that --dev + --from does not show a
# confusing confirmation for a URL that will be ignored.
if from_url and not dev:
from urllib.parse import urlparse
try:
parsed = urlparse(from_url)
# Read .hostname inside the try: parsing a malformed authority -- or
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
# parse and the .hostname read inside the guard surfaces a clean
# "Invalid URL" message instead of leaking a raw traceback past the
# CLI. Reuse the value below.
hostname = parsed.hostname
parsed.port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
if not hostname:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
if not is_https_or_localhost_http(from_url):
console.print("[red]Error:[/red] URL must use HTTPS for security.")
console.print("HTTP is only allowed for loopback URLs.")
raise typer.Exit(1)
safe_url = _escape_markup(from_url)
# Warn about untrusted sources — default-deny confirmation
console.print()
console.print(Panel(
f"[bold]You are installing an extension from an external URL that is not\n"
f"listed in any of your configured extension catalogs.[/bold]\n\n"
f"URL: {safe_url}\n\n"
f"Only install extensions from sources you trust.",
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
border_style="yellow",
padding=(1, 2),
))
console.print()
confirm = typer.confirm("Continue with installation?", default=False)
if not confirm:
console.print("Cancelled")
raise typer.Exit(0)
safe_extension = _escape_markup(extension)
try:
with console.status(f"[cyan]Installing extension: {safe_extension}[/cyan]"):
if dev:
# Install from local directory
source_path = Path(extension).expanduser().resolve()
safe_source_path = _escape_markup(str(source_path))
if not source_path.exists():
console.print(f"[red]Error:[/red] Directory not found: {safe_source_path}")
raise typer.Exit(1)
if not (source_path / "extension.yml").exists():
console.print(f"[red]Error:[/red] No extension.yml found in {safe_source_path}")
raise typer.Exit(1)
if force:
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{safe_source_path}[/cyan] (will overwrite if already installed)...")
manifest = manager.install_from_directory(
source_path,
speckit_version,
priority=priority,
link_commands=True,
force=force
)
elif from_url:
# Install from URL archive via the shared hardened downloader
# (HTTPS enforcement, authenticated redirect-guarded fetch,
# bounded read, archive-format detection, TOCTOU-safe transient
# archive). Same path used by ``specify init --extension <url>``.
console.print(f"Downloading from {safe_url}...")
manifest = install_extension_from_url(
manager,
project_root,
from_url,
speckit_version,
priority=priority,
force=force,
)
else:
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
if bundled_path is not None:
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
else:
# Install from catalog (also resolves display names to IDs)
catalog = ExtensionCatalog(project_root)
# Check if extension exists in catalog (supports both ID and display name)
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
if catalog_error:
console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}")
raise typer.Exit(1)
if not ext_info:
console.print(f"[red]Error:[/red] Extension '{safe_extension}' not found in catalog")
console.print("\nSearch available extensions:")
console.print(" specify extension search")
raise typer.Exit(1)
# If catalog resolved a display name to an ID, check bundled again
resolved_id = ext_info['id']
if resolved_id != extension:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
if ext_info.get("bundled") and not ext_info.get("download_url"):
console.print(
f"[red]Error:[/red] Extension '{_escape_markup(ext_info['id'])}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
console.print(
f"[red]Error:[/red] '{safe_extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
)
console.print(
f"\nTo enable installation, add '{safe_extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
)
raise typer.Exit(1)
# Download extension archive (use the resolved catalog ID).
extension_id = ext_info['id']
console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...")
archive_path = catalog.download_extension(extension_id)
try:
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
)
finally:
if archive_path.exists():
archive_path.unlink()
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
console.print(f" {_escape_markup(str(manifest.description))}")
# #1: regenerate native event config for installed event-capable
# integrations so the new extension's events take effect immediately.
_refresh_events_and_warn(project_root)
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
selected_ai = load_init_options(project_root).get("ai")
is_cline = selected_ai == "cline"
is_forge = selected_ai == "forge"
if is_cline:
from specify_cli.integrations.cline import format_cline_command_name
if is_forge:
from specify_cli.integrations.forge import format_forge_command_name
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
elif is_forge:
cmd_name = format_forge_command_name(cmd_name)
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
# Report agent skills registration
reg_meta = manager.registry.get(manifest.id)
reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
# Normalize to guard against corrupted registry entries
if not isinstance(reg_skills, list):
reg_skills = []
if reg_skills:
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
# Scaffold config templates automatically
deployed, skipped, failed = manager.scaffold_config(manifest.id)
config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
# Only warn when configuration is actually unresolved. Scaffolding that
# deployed or preserved every template has already answered this, and an
# extension without provides.config has nothing to configure; the blanket
# warning contradicted the output directly above it.
if failed or not (deployed or skipped):
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: {config_home}/")
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except CompatibilityError as e:
console.print(f"\n[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@extension_app.command("remove")
def extension_remove(
extension: str = typer.Argument(help="Extension ID or name to remove"),
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
):
"""Uninstall an extension."""
from . import ExtensionManager
project_root = _require_specify_project()
manager = ExtensionManager(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
safe_extension_id = _escape_markup(str(extension_id))
# Get extension info for command and skill counts
ext_manifest = manager.get_extension(extension_id)
reg_meta = manager.registry.get(extension_id)
# Derive cmd_count from the registry's registered_commands (includes aliases)
# rather than from the manifest (primary commands only). Use max() across
# agents to get the per-agent count; sum() would double-count since users
# think in logical commands, not per-agent file counts.
# Use get() without a default so we can distinguish "key missing" (fall back
# to manifest) from "key present but empty dict" (zero commands registered).
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
if isinstance(registered_commands, dict):
cmd_count = max(
(len(v) for v in registered_commands.values() if isinstance(v, list)),
default=0,
)
else:
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
# Confirm removal
if not force:
console.print("\n[yellow]⚠ This will remove:[/yellow]")
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
if skill_count:
console.print(f" • {skill_count} agent skill(s)")
console.print(f" • Extension directory: .specify/extensions/{safe_extension_id}/")
if not keep_config:
console.print(" • Config files (will be backed up)")
console.print()
confirm = typer.confirm("Continue?")
if not confirm:
console.print("Cancelled")
raise typer.Exit(0)
# Remove extension
success = manager.remove(extension_id, keep_config=keep_config)
if success:
console.print(f"\n[green]✓[/green] Extension '{_escape_markup(str(display_name))}' removed successfully")
if keep_config:
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
else:
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
# #1: regenerate native event config so the removed extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
else:
console.print("[red]Error:[/red] Failed to remove extension")
raise typer.Exit(1)
@extension_app.command("search")
def extension_search(
query: str = typer.Argument(None, help="Search query (optional)"),
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
):
"""Search for available extensions in catalog."""
from . import ExtensionCatalog, ExtensionError
project_root = _require_specify_project()
catalog = ExtensionCatalog(project_root)
try:
console.print("🔍 Searching extension catalog...")
results = catalog.search(query=query, tag=tag, author=author, verified_only=verified)
if not results:
console.print("\n[yellow]No extensions found matching criteria[/yellow]")
if query or tag or author or verified:
console.print("\nTry:")
console.print(" • Broader search terms")
console.print(" • Remove filters")
console.print(" • specify extension search (show all)")
raise typer.Exit(0)
console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n")
for ext in results:
# Extension header
verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
console.print(f"[bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}")
console.print(f" {_escape_markup(str(ext['description']))}")
# Metadata
console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}")
ext_tags = ext.get('tags', [])
if isinstance(ext_tags, list) and ext_tags:
tags_str = ", ".join(str(t) for t in ext_tags)
console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}")
# Source catalog
catalog_name = _escape_markup(str(ext.get("_catalog_name", "")))
install_allowed = ext.get("_install_allowed", True)
if catalog_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
else:
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
# Stats
stats = []
downloads = ext.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads``
# (e.g. the JSON string "1500") would crash the ``:,`` format
# with "Cannot specify ',' with 's'". Only group-format numbers,
# and escape the fallback: the joined stats are rendered as Rich
# markup, so a value like "[/red]foo" would raise MarkupError
# (matching how every other catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above,
# in the same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f" [dim]{' | '.join(stats)}[/dim]")
# Links
if ext.get('repository'):
console.print(f" [dim]Repository:[/dim] {_escape_markup(str(ext['repository']))}")
# Install command (show warning if not installable)
safe_id = _escape_markup(str(ext['id']))
if install_allowed:
console.print(f"\n [cyan]Install:[/cyan] specify extension add {safe_id}")
else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
f" Add to an approved catalog with install_allowed: true, "
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
)
console.print()
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}")
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
raise typer.Exit(1)
@extension_app.command("info")
def extension_info(
extension: str = typer.Argument(help="Extension ID or name"),
):
"""Show detailed information about an extension."""
from . import ExtensionCatalog, ExtensionManager, normalize_priority
project_root = _require_specify_project()
catalog = ExtensionCatalog(project_root)
manager = ExtensionManager(project_root)
installed = manager.list_installed()
# Try to resolve from installed extensions first (by ID or name)
# Use allow_not_found=True since the extension may be catalog-only
resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
extension, installed, "info", allow_not_found=True
)
# Try catalog lookup (with error handling)
# If we resolved an installed extension by display name, use its ID for catalog lookup
# to ensure we get the correct catalog entry (not a different extension with same name)
lookup_key = resolved_installed_id if resolved_installed_id else extension
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
# Case 1: Found in catalog - show full catalog info
if ext_info:
_print_extension_info(ext_info, manager)
return
# Case 2: Installed locally but catalog lookup failed or not in catalog
if resolved_installed_id:
# Get local manifest info
ext_manifest = manager.get_extension(resolved_installed_id)
metadata = manager.registry.get(resolved_installed_id)
metadata_is_dict = isinstance(metadata, dict)
if not metadata_is_dict:
console.print(
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
"some information may be unavailable."
)
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
console.print(f"\n[bold]{_escape_markup(str(resolved_installed_name))}[/bold] (v{_escape_markup(str(version))})")
console.print(f"ID: {_escape_markup(str(resolved_installed_id))}")
console.print()
if ext_manifest:
console.print(f"{_escape_markup(str(ext_manifest.description))}")
console.print()
# Author is optional in extension.yml, safely retrieve it
author = ext_manifest.data.get("extension", {}).get("author")
if author:
console.print(f"[dim]Author:[/dim] {_escape_markup(str(author))}")
if ext_manifest.category:
console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_manifest.category))}")
if ext_manifest.effect:
console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_manifest.effect))}")
console.print()
if ext_manifest.commands:
# Print each command the way the active agent registers it.
# Cline and Forge hyphenate command names (e.g. Forge invokes
# `/speckit-jira-sync`, not the manifest's dotted
# `speckit.jira.sync`), so mirror the same formatting used by
# `extension add`'s "Provided commands" listing — otherwise the
# names shown here don't match what the user actually types.
selected_ai = load_init_options(project_root).get("ai")
if selected_ai == "cline":
from specify_cli.integrations.cline import (
format_cline_command_name as _format_command_name,
)
elif selected_ai == "forge":
from specify_cli.integrations.forge import (
format_forge_command_name as _format_command_name,
)
else:
_format_command_name = None
console.print("[bold]Commands:[/bold]")
for cmd in ext_manifest.commands:
cmd_name = cmd['name']
if _format_command_name is not None:
cmd_name = _format_command_name(cmd_name)
console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}")
console.print()
# Show catalog status
if catalog_error:
console.print(f"[yellow]Catalog unavailable:[/yellow] {_escape_markup(str(catalog_error))}")
console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
else:
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
console.print()
console.print("[green]✓ Installed[/green]")
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {_escape_markup(str(resolved_installed_id))}")
return
# Case 3: Not found anywhere
if catalog_error:
console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}")
console.print("\nTry again when online, or use the extension ID directly.")
else:
console.print(f"[red]Error:[/red] Extension '{_escape_markup(extension)}' not found")
console.print("\nTry: specify extension search")
raise typer.Exit(1)
def _print_extension_info(ext_info: dict, manager):
"""Print formatted extension info from catalog data."""
from . import normalize_priority
# Header
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
console.print(f"\n[bold]{_escape_markup(str(ext_info['name']))}[/bold] (v{_escape_markup(str(ext_info['version']))}){verified_badge}")
console.print(f"ID: {_escape_markup(str(ext_info['id']))}")
console.print()
# Description
console.print(f"{_escape_markup(str(ext_info['description']))}")
console.print()
# Author and License
console.print(f"[dim]Author:[/dim] {_escape_markup(str(ext_info.get('author', 'Unknown')))}")
console.print(f"[dim]License:[/dim] {_escape_markup(str(ext_info.get('license', 'Unknown')))}")
# Category and Effect
if ext_info.get('category'):
console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_info['category']))}")
if ext_info.get('effect'):
console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_info['effect']))}")
# Source catalog
if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True)
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f"[dim]Source catalog:[/dim] {_escape_markup(str(ext_info['_catalog_name']))}{install_note}")
console.print()
# Requirements
if ext_info.get('requires'):
console.print("[bold]Requirements:[/bold]")
reqs = ext_info['requires']
if reqs.get('speckit_version'):
console.print(f" • Spec Kit: {_escape_markup(str(reqs['speckit_version']))}")
if reqs.get('tools'):
for tool in reqs['tools']:
tool_name = _escape_markup(str(tool['name']))
tool_version = _escape_markup(str(tool.get('version', 'any')))
required = " (required)" if tool.get('required') else " (optional)"
console.print(f" • {tool_name}: {tool_version}{required}")
console.print()
# Provides
if ext_info.get('provides'):
console.print("[bold]Provides:[/bold]")
provides = ext_info['provides']
if provides.get('commands'):
console.print(f" • Commands: {_escape_markup(str(provides['commands']))}")
if provides.get('hooks'):
console.print(f" • Hooks: {_escape_markup(str(provides['hooks']))}")
console.print()
# Tags
info_tags = ext_info.get('tags', [])
if isinstance(info_tags, list) and info_tags:
tags_str = ", ".join(str(t) for t in info_tags)
console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}")
console.print()
# Statistics
stats = []
downloads = ext_info.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the
# JSON string "1500") would crash the ``:,`` format with "Cannot
# specify ',' with 's'". Only group-format numbers, and escape the
# fallback: the joined stats are rendered as Rich markup, so a value
# like "[/red]foo" would raise MarkupError (matching how every other
# catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext_info.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above, in the
# same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
console.print()
# Links
console.print("[bold]Links:[/bold]")
if ext_info.get('repository'):
console.print(f" • Repository: {_escape_markup(str(ext_info['repository']))}")
if ext_info.get('homepage'):
console.print(f" • Homepage: {_escape_markup(str(ext_info['homepage']))}")
if ext_info.get('documentation'):
console.print(f" • Documentation: {_escape_markup(str(ext_info['documentation']))}")
if ext_info.get('changelog'):
console.print(f" • Changelog: {_escape_markup(str(ext_info['changelog']))}")
console.print()
# Installation status and command
is_installed = manager.registry.is_installed(ext_info['id'])
install_allowed = ext_info.get("_install_allowed", True)
safe_id = _escape_markup(str(ext_info['id']))
if is_installed:
console.print("[green]✓ Installed[/green]")
metadata = manager.registry.get(ext_info['id'])
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {safe_id}")
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
console.print(f"\n[cyan]Install:[/cyan] specify extension add {safe_id}")
else:
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
console.print("[yellow]Not installed[/yellow]")
console.print(
f"\n[yellow]⚠[/yellow] '{safe_id}' is available in the '{catalog_name}' catalog "
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
f"with install_allowed: true to enable installation."
)
@extension_app.command("update")
def extension_update(
extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
):
"""Update extension(s) to latest version."""
from . import (
ExtensionManager,
ExtensionCatalog,
ExtensionManifest,
ExtensionError,
ValidationError,
CommandRegistrar,
HookExecutor,
normalize_priority,
)
from packaging import version as pkg_version
project_root = _require_specify_project()
manager = ExtensionManager(project_root)
catalog = ExtensionCatalog(project_root)
speckit_version = get_speckit_version()
try:
# Get list of extensions to update
installed = manager.list_installed()
if extension:
# Update specific extension - resolve ID from argument (handles ambiguous names)
extension_id, _ = _resolve_installed_extension(extension, installed, "update")
extensions_to_update = [extension_id]
else:
# Update all extensions
extensions_to_update = [ext["id"] for ext in installed]
if not extensions_to_update:
console.print("[yellow]No extensions installed[/yellow]")
raise typer.Exit(0)
console.print("🔄 Checking for updates...\n")
updates_available = []
for ext_id in extensions_to_update:
safe_ext_id = _escape_markup(str(ext_id))
# Get installed version
metadata = manager.registry.get(ext_id)
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
console.print(f"⚠ {safe_ext_id}: Registry entry corrupted or missing (skipping)")
continue
try:
installed_version = pkg_version.Version(metadata["version"])
except pkg_version.InvalidVersion:
console.print(
f"⚠ {safe_ext_id}: Invalid installed version '{_escape_markup(str(metadata.get('version')))}' in registry (skipping)"
)
continue
# Get catalog info
ext_info = catalog.get_extension_info(ext_id)
if not ext_info:
console.print(f"⚠ {safe_ext_id}: Not found in catalog (skipping)")
continue
# Check if installation is allowed from this catalog
if not ext_info.get("_install_allowed", True):
console.print(f"⚠ {safe_ext_id}: Updates not allowed from '{_escape_markup(str(ext_info.get('_catalog_name', 'catalog')))}' (skipping)")
continue
try:
catalog_version = pkg_version.Version(ext_info["version"])
except pkg_version.InvalidVersion:
console.print(
f"⚠ {safe_ext_id}: Invalid catalog version '{_escape_markup(str(ext_info.get('version')))}' (skipping)"
)
continue
if catalog_version > installed_version:
updates_available.append(
{
"id": ext_id,
"name": ext_info.get("name", ext_id), # Display name for status messages
"installed": str(installed_version),
"available": str(catalog_version),
"download_url": ext_info.get("download_url"),
}
)
else:
console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})")
if not updates_available:
console.print("\n[green]All extensions are up to date![/green]")
raise typer.Exit(0)
# Show available updates
console.print("\n[bold]Updates available:[/bold]\n")
for update in updates_available:
console.print(
f" • {_escape_markup(str(update['id']))}: {update['installed']} → {update['available']}"
)
console.print()
confirm = typer.confirm("Update these extensions?")
if not confirm:
console.print("Cancelled")
raise typer.Exit(0)
# Perform updates with atomic backup/restore
console.print()
updated_extensions = []
failed_updates = []
registrar = CommandRegistrar()
hook_executor = HookExecutor(project_root)
from ..agents import CommandRegistrar as _AgentReg # used in backup and rollback paths
# UNSET sentinel: backup not yet captured (exception before backup step)
UNSET = object()
for update in updates_available:
extension_id = update["id"]
ext_name = update["name"] # Use display name for user-facing messages
safe_ext_name = _escape_markup(str(ext_name))
console.print(f"📦 Updating {safe_ext_name}...")
# Backup paths
backup_root = manager.extensions_dir / ".backup"
backup_key = hashlib.sha256(
extension_id.encode("utf-8")
).hexdigest()[:16]
backup_base = (
backup_root
/ f"update-{backup_key}-{uuid4().hex}"
)
backup_ext_dir = backup_base / "extension"
backup_commands_dir = backup_base / "commands"
backup_skills_dir = backup_base / "skills"
backup_config_dir = backup_base / "config"
# Store backup state
backup_registry_entry = None # None means registry entry not yet captured
backup_installed = UNSET # Original installed list from extensions.yml
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
backed_up_command_files = {}
backed_up_command_symlinks = {}
backed_up_skill_dirs = {}
new_command_dirs_absent_before_update = []
new_command_paths_absent_before_update = []
new_skill_names = []
new_skill_paths_absent_before_update = []
# Validation failures must not rewrite an untouched installation.
installation_modified = False
zip_cleanup_error = None
backup_created_by_attempt = False
def backup_command_artifact(original_file, backup_file):
"""Back up one command artifact once, preserving its full path."""
nonlocal backup_created_by_attempt
original_key = str(original_file)
if original_key in backed_up_command_files:
return
if original_file.is_symlink():
backed_up_command_symlinks[original_key] = os.readlink(
original_file
)
else:
if original_file.stat().st_nlink > 1:
raise RuntimeError(
"Cannot safely update hard-linked generated "
f"artifact '{original_file}'"
)
backup_created_by_attempt = True
backup_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(original_file, backup_file)
backed_up_command_files[original_key] = str(backup_file)
def restore_command_artifact(original_path, backup_path):
"""Restore one regular file or symlink without following it."""
original_key = str(original_path)
original_file = Path(original_path)
backup_file = Path(backup_path)
symlink_state = backed_up_command_symlinks.get(
original_key
)
if symlink_state is not None:
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
os.symlink(symlink_state, original_file)
return
if not backup_file.is_file() or backup_file.is_symlink():
raise RuntimeError(
"Command rollback backup is missing for "
f"'{original_file}'"
)
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, original_file)
def remember_absent_parent_dirs(artifact_path, root_dir):
"""Remember absent parents a failed renderer may create."""
boundary = root_dir.parent
if root_dir.is_relative_to(project_root):
boundary = project_root
parent = artifact_path.parent
while parent != boundary:
if parent.exists() or parent.is_symlink():
break
new_command_dirs_absent_before_update.append(parent)
parent = parent.parent
def backup_extension_skills(skill_names, *, skills_dir=None):
"""Back up every owned skill directory that remove() may delete."""
nonlocal backup_created_by_attempt
for skill_dir in manager._find_extension_skill_dirs(
skill_names,
extension_id,
skills_dir=skills_dir,
create_skills_dir=False,
):
original_key = str(skill_dir)
if original_key in backed_up_skill_dirs:
continue
backup_created_by_attempt = True
backup_skills_dir.mkdir(parents=True, exist_ok=True)
backup_skill_dir = backup_skills_dir / str(
len(backed_up_skill_dirs)
)
shutil.copytree(skill_dir, backup_skill_dir, symlinks=True)
backed_up_skill_dirs[original_key] = str(backup_skill_dir)
try:
if backup_root.is_symlink():
raise RuntimeError(
"Cannot safely create update backup under symlinked "
f"directory '{backup_root}'"
)
if backup_base.exists() or backup_base.is_symlink():
raise RuntimeError(
"Cannot safely reuse an existing update backup "
f"directory '{backup_base}'"
)
# 1. Backup registry entry (always, even if extension dir doesn't exist)
backup_registry_entry = manager.registry.get(extension_id)
# 2. Backup extension directory
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
backup_created_by_attempt = True
backup_base.mkdir(parents=True, exist_ok=True)
if backup_ext_dir.exists():
shutil.rmtree(backup_ext_dir)
shutil.copytree(extension_dir, backup_ext_dir)
# Backup config files separately so they can be restored
# after a successful install (install_from_directory clears dest dir).
config_files = list(extension_dir.glob("*-config.yml")) + list(
extension_dir.glob("*-config.local.yml")
)
for cfg_file in config_files:
backup_config_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
# 3. Backup command files for all agents
registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {}
for agent_name, cmd_names in registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
agent_config = registrar.AGENT_CONFIGS[agent_name]
commands_dir = _AgentReg._resolve_agent_dir(
agent_name, agent_config, project_root
)
dirs_to_backup = [commands_dir]
legacy = agent_config.get("legacy_dir")
if legacy:
legacy_dir = project_root / legacy
if (
legacy_dir.exists()
and legacy_dir != commands_dir
):
dirs_to_backup.append(legacy_dir)
for cmd_name in cmd_names:
output_name = _AgentReg._compute_output_name(
agent_name, cmd_name, agent_config
)
names_to_backup = [output_name]
if (
output_name != cmd_name
and _AgentReg._is_safe_command_name(cmd_name)
):
names_to_backup.append(cmd_name)
for dir_index, target_dir in enumerate(
dirs_to_backup
):
for name in names_to_backup:
cmd_file = (
target_dir
/ f"{name}{agent_config['extension']}"
)
try:
_AgentReg._ensure_inside(
cmd_file, target_dir
)
except ValueError:
continue
if (
cmd_file.exists()
or cmd_file.is_symlink()
):
# Keep both the directory location and
# relative path unique. unregister_commands()
# removes legacy and canonical copies, and
# skills agents place every SKILL.md in its
# own command subdirectory.
backup_cmd_path = (
backup_commands_dir
/ agent_name
/ f"location-{dir_index}"
/ cmd_file.relative_to(target_dir)
)
backup_command_artifact(
cmd_file, backup_cmd_path
)
# Also backup copilot prompt files
if agent_name == "copilot":
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{cmd_name}.prompt.md"
)
try:
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
except ValueError:
continue
if prompt_file.exists() or prompt_file.is_symlink():
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
backup_command_artifact(
prompt_file, backup_prompt_path
)
raw_registered_skills = (
backup_registry_entry.get("registered_skills", [])
if isinstance(backup_registry_entry, dict)
else []
)
registered_skills = manager._valid_name_list(raw_registered_skills)
backup_extension_skills(registered_skills)
# 4. Backup hooks and installed list from extensions.yml
# get_project_config() always normalizes installed->[] and hooks->{},
# so no sentinel is needed to distinguish key-absent from key-empty.
config = hook_executor.get_project_config()
if isinstance(config, dict):
import copy
# Deep-copy so nested mapping entries (e.g. version-pin dicts)
# are not affected by in-place mutations during the update.
backup_installed = copy.deepcopy(config.get("installed", []))
backup_hooks = {}
for hook_name, hook_list in config.get("hooks", {}).items():
if not isinstance(hook_list, list):
continue
ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id]
if ext_hooks:
backup_hooks[hook_name] = ext_hooks
# 5. Download new version
archive_path = catalog.download_extension(extension_id)
try:
# 6. Validate the archive and extension ID before modifying
# the existing installation. The shared extractor applies
# the same bounded security checks to ZIP and tar archives.
with tempfile.TemporaryDirectory(
prefix="speckit-update-archive-"
) as archive_tmpdir:
extracted_root = Path(archive_tmpdir)
try:
safe_extract_archive(archive_path, extracted_root)
except ValueError as exc:
if (
"Conflicting path" in str(exc)
and "extension.yml" in str(exc).casefold()
):
raise ValueError(
"Downloaded extension archive contains multiple "
"extension.yml manifests"
) from exc
raise
manifest_root = extracted_root
top_level = list(extracted_root.iterdir())
root_manifest_entries = [
entry
for entry in top_level
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in root_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
canonical_root_manifest = next(
(
entry
for entry in root_manifest_entries
if entry.name == "extension.yml"
),
None,
)
if canonical_root_manifest is not None:
manifest_path = canonical_root_manifest
else:
top_level_dirs = [
entry for entry in top_level if entry.is_dir()
]
if len(top_level_dirs) != 1:
raise ValueError(
"Downloaded extension archive must contain exactly "
"one top-level directory"
)
manifest_root = top_level_dirs[0]
nested_manifest_entries = [
entry
for entry in manifest_root.iterdir()
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in nested_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
manifest_path = next(
(
entry
for entry in nested_manifest_entries
if entry.name == "extension.yml"
),
manifest_root / "extension.yml",
)
if not manifest_path.is_file():
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
manifest_bytes = manifest_path.read_bytes()
parsed_manifest = yaml.safe_load(manifest_bytes)
manifest_data = (
parsed_manifest if parsed_manifest is not None else {}
)
if not isinstance(manifest_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: "
"expected YAML mapping"
)
extension_data = manifest_data.get("extension", {})
if not isinstance(extension_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: "
"expected 'extension' mapping"
)
# Run the same manifest and compatibility validation as a
# normal install while the existing extension is still
# untouched. Reuse the exact bounded bytes selected above.
with tempfile.TemporaryDirectory(
prefix="speckit-update-manifest-"
) as manifest_tmpdir:
manifest_file = Path(manifest_tmpdir) / "extension.yml"
manifest_file.write_bytes(manifest_bytes)
preflight_manifest = ExtensionManifest(manifest_file)
manager.check_compatibility(
preflight_manifest, speckit_version
)
zip_extension_id = preflight_manifest.id
if zip_extension_id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
)
expected_version = pkg_version.Version(update["available"])
archive_version = pkg_version.Version(
preflight_manifest.version
)
if archive_version != expected_version:
raise ValueError(
"Extension version mismatch: "
f"expected '{update['available']}', "
f"got '{preflight_manifest.version}'"
)
# Match the remaining deterministic install validation
# before crossing the destructive boundary. The helper
# excludes this extension's current registry entry while
# still detecting namespace, core, duplicate, and
# cross-extension command conflicts.
manager._validate_install_conflicts(preflight_manifest)
new_command_names = list(
manager._collect_manifest_command_names(
preflight_manifest
)
)
new_skill_names = list(
dict.fromkeys(
manager._skill_name_for_command(command_name)
for command_name in new_command_names
)
)
# Command rendering happens before hook registration and
# registry.add(). Preserve every candidate output that
# already exists, and remember paths that are absent now so
# rollback can remove files created before registry state is
# available. Include aliases and Copilot companion prompts.
for (
agent_name,
commands_dir,
) in manager._command_registration_targets().items():
agent_config = registrar.AGENT_CONFIGS[agent_name]
for command_name in new_command_names:
output_name = _AgentReg._compute_output_name(
agent_name, command_name, agent_config
)
command_file = (
commands_dir
/ f"{output_name}{agent_config['extension']}"
)
_AgentReg._ensure_inside(command_file, commands_dir)
backup_command_path = (
backup_commands_dir
/ agent_name
/ command_file.relative_to(commands_dir)
)
if command_file.exists() or command_file.is_symlink():
backup_command_artifact(
command_file, backup_command_path
)
else:
new_command_paths_absent_before_update.append(
command_file
)
remember_absent_parent_dirs(
command_file, commands_dir
)
if agent_name == "copilot":
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{command_name}.prompt.md"
)
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
if prompt_file.is_symlink():
raise RuntimeError(
"Cannot safely update symlinked Copilot "
f"prompt artifact '{prompt_file}'"
)
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
if (
prompt_file.exists()
or prompt_file.is_symlink()
):
backup_command_artifact(
prompt_file, backup_prompt_path
)
else:
new_command_paths_absent_before_update.append(
prompt_file
)
remember_absent_parent_dirs(
prompt_file, prompts_dir
)
new_command_paths_absent_before_update = list(
dict.fromkeys(
new_command_paths_absent_before_update
)
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# A newly introduced command may reuse an existing
# extension-owned skill directory that was not present in
# the old registry. Back it up before cleanup can touch it.
backup_extension_skills(new_skill_names)
new_skills_dir = manager._get_skills_dir(create=False)
if new_skills_dir is not None:
# Unscoped removal deliberately ignores home-scoped
# outputs because the flat registry cannot establish
# project ownership. The active install can still
# replace a marker-owned skill in its explicit root,
# so back up that exact project/home target separately.
backup_extension_skills(
list(
dict.fromkeys(
registered_skills + new_skill_names
)
),
skills_dir=new_skills_dir,
)
init_options = load_init_options(project_root)
if (
isinstance(init_options, dict)
and is_ai_skills_enabled(init_options)
and isinstance(init_options.get("ai"), str)
and init_options["ai"]
):
# resolve_active_skills_dir() first creates the
# configured project-local skills marker. Some
# agents (notably Hermes) then redirect rendered
# skills to a different global root, so snapshot
# both locations for exact rollback.
from .. import _get_skills_dir
configured_skills_dir = _get_skills_dir(
project_root, init_options["ai"]
)
remember_absent_parent_dirs(
configured_skills_dir / ".update-marker",
configured_skills_dir,
)
new_skills_root = new_skills_dir.resolve()
for skill_name in new_skill_names:
skill_path = new_skills_dir / skill_name
resolved_skill_path = skill_path.resolve(strict=False)
resolved_skill_path.relative_to(new_skills_root)
if not (
skill_path.exists() or skill_path.is_symlink()
):
new_skill_paths_absent_before_update.append(
skill_path
)
remember_absent_parent_dirs(
skill_path / "SKILL.md",
new_skills_dir,
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# 7. Remove old extension (handles command file cleanup and registry removal)
installation_modified = True
manager.remove(extension_id, keep_config=True)
# 8. Install new version
_ = manager.install_from_zip(archive_path, speckit_version)
# Restore user config files from backup after successful install.
new_extension_dir = manager.extensions_dir / extension_id
if backup_config_dir.exists() and new_extension_dir.exists():
for cfg_file in backup_config_dir.iterdir():
if cfg_file.is_file():
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
# 9. Restore metadata from backup (installed_at, enabled state)
if backup_registry_entry and isinstance(backup_registry_entry, dict):
# Copy current registry entry to avoid mutating internal
# registry state before explicit restore().
current_metadata = manager.registry.get(extension_id)
if current_metadata is None or not isinstance(current_metadata, dict):
raise RuntimeError(
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
)
new_metadata = dict(current_metadata)
# Preserve the original installation timestamp
if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
# Preserve the original priority (normalized to handle corruption)
if "priority" in backup_registry_entry:
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
# If extension was disabled before update, disable it again
if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False
# Use restore() instead of update() because update() always
# preserves the existing installed_at, ignoring our override
manager.registry.restore(extension_id, new_metadata)
# Also disable hooks in extensions.yml if extension was disabled
if not backup_registry_entry.get("enabled", True):
config = hook_executor.get_project_config()
if "hooks" in config:
for hook_name in config["hooks"]:
for hook in config["hooks"][hook_name]:
if hook.get("extension") == extension_id:
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# Archive cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
if archive_path.exists():
try:
archive_path.unlink()
except OSError as error:
zip_cleanup_error = error
# 10. Clean up backup on success. The update has committed at
# this point, so a locked backup file must not trigger rollback
# of an otherwise successful installation.
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(f" [green]✓[/green] Updated to v{update['available']}")
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully remove "
"update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
updated_extensions.append(ext_name)
except KeyboardInterrupt:
raise
except Exception as e:
console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}")
failed_updates.append((ext_name, str(e)))
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
if not installation_modified:
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as cleanup_error:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"untouched-update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
continue
# Rollback on failure
console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...")
try:
# Restore extension directory
# Only perform destructive rollback if backup exists (meaning we
# actually modified the extension). This avoids deleting a valid
# installation when failure happened before changes were made.
extension_dir = manager.extensions_dir / extension_id
if backup_ext_dir.exists():
if extension_dir.exists():
shutil.rmtree(extension_dir)
shutil.copytree(backup_ext_dir, extension_dir)
# Remove any NEW command files created by failed install
# (files that weren't in the original backup). Registration
# writes before registry.add(), so start with the paths that
# were absent at the destructive boundary instead of relying
# only on a possibly missing new registry entry.
for command_path in new_command_paths_absent_before_update:
if command_path.is_symlink() or command_path.is_file():
command_path.unlink()
elif command_path.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{command_path}'"
)
new_registered_skills = []
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
new_registered_skills = manager._valid_name_list(
new_registry_entry.get("registered_skills", [])
)
for agent_name, cmd_names in new_registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
agent_config = registrar.AGENT_CONFIGS[agent_name]
commands_dir = _AgentReg._resolve_agent_dir(
agent_name, agent_config, project_root
)
for cmd_name in cmd_names:
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
# Delete if it exists and wasn't in our backup
if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
cmd_file.unlink()
# Also handle copilot prompt files
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
prompt_file.unlink()
except KeyError:
pass # No new registry entry exists, nothing to clean up
# Restore command artifacts that existed before the update
# before extension-skill cleanup inspects ownership. A
# failed skills registrar may have overwritten a user's
# pre-existing SKILL.md with extension metadata; restoring
# it first prevents the conservative skill unregistrar from
# misclassifying and deleting the user's whole directory.
for original_path, backup_path in backed_up_command_files.items():
restore_command_artifact(
original_path, backup_path
)
# Skill generation happens before hooks and registry.add(),
# so a failed install may have created skills that are not
# recorded in any registry entry yet. Derive names from the
# preflighted manifest as well as any partial new entry.
skills_to_remove = list(
dict.fromkeys(new_skill_names + new_registered_skills)
)
# A write failure can leave a partial skill without valid
# ownership metadata, which the normal conservative
# unregistrar intentionally refuses to delete. Paths that
# were absent at the destructive boundary are safe to
# remove directly during rollback.
for skill_path in new_skill_paths_absent_before_update:
if skill_path.is_symlink() or skill_path.is_file():
skill_path.unlink()
elif skill_path.exists():
shutil.rmtree(skill_path)
manager._unregister_extension_skills(
skills_to_remove, extension_id
)
# Restore all original registered skill artifacts after
# removing skills created by the failed installation.
for original_path, backup_path in backed_up_skill_dirs.items():
backup_skill_dir = Path(backup_path)
if not backup_skill_dir.is_dir():
raise RuntimeError(
"Skill rollback backup is missing for "
f"'{original_path}'"
)
original_skill_dir = Path(original_path)
if (
original_skill_dir.is_symlink()
or original_skill_dir.is_file()
):
original_skill_dir.unlink()
elif original_skill_dir.exists():
shutil.rmtree(original_skill_dir)
original_skill_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
backup_skill_dir,
original_skill_dir,
symlinks=True,
)
# Remove empty artifact directories that did not exist at
# the destructive boundary. Do this after skill cleanup and
# restoration so newly created skills roots and their
# project-local parents can also be removed exactly.
for command_dir in sorted(
new_command_dirs_absent_before_update,
key=lambda path: len(path.parts),
reverse=True,
):
if command_dir.is_dir() and not command_dir.is_symlink():
try:
command_dir.rmdir()
except OSError:
# Preserve any non-empty directory: other
# content may belong to the user.
pass
# Restore metadata in extensions.yml (hooks and installed list).
# Only run if backup step 4 was reached (backup_hooks is not None);
# otherwise we have no safe baseline to restore from and could corrupt
# the config by removing pre-existing hooks.
if backup_hooks is not None:
config = hook_executor.get_project_config()
if not isinstance(config, dict):
config = {}
modified = False
# 1. Restore hooks in extensions.yml
if not isinstance(config.get("hooks"), dict):
config["hooks"] = {}
modified = True
# Remove any hooks for this extension added by the failed install
for hook_name in list(config["hooks"].keys()):
hooks_list = config["hooks"][hook_name]
if not isinstance(hooks_list, list):
config["hooks"][hook_name] = []
modified = True
continue
original_len = len(hooks_list)
config["hooks"][hook_name] = [
h for h in hooks_list
if isinstance(h, dict) and h.get("extension") != extension_id
]
if len(config["hooks"][hook_name]) != original_len:
modified = True
# Add back the backed-up hooks
if backup_hooks:
for hook_name, hooks in backup_hooks.items():
if not isinstance(config["hooks"].get(hook_name), list):
config["hooks"][hook_name] = []
config["hooks"][hook_name].extend(hooks)
modified = True
# 2. Restore installed list in extensions.yml
if backup_installed is not UNSET:
if config.get("installed") != backup_installed:
config["installed"] = backup_installed
modified = True
if modified:
hook_executor.save_project_config(config)
# Restore registry entry (use restore() since entry was removed)
if backup_registry_entry:
manager.registry.restore(extension_id, backup_registry_entry)
# Backup cleanup is post-rollback housekeeping. A locked
# file (notably on Windows) must not turn successfully
# restored state into a contradictory "Rollback failed".
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(" [green]✓[/green] Rollback successful")
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully "
"remove rollback backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
except Exception as rollback_error:
console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}")
console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]")
# Summary
console.print()
if updated_extensions:
console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
if failed_updates:
console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
for ext_name, error in failed_updates:
console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
raise typer.Exit(1)
# S4: regenerate native event config after a successful update. An
# update replaces the installed extension.yml, so any added/removed/
# changed event declarations would otherwise leave native configs
# stale until a manual integration upgrade.
if updated_extensions:
_refresh_events_and_warn(project_root)
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@extension_app.command("enable")
def extension_enable(
extension: str = typer.Argument(help="Extension ID or name to enable"),
):
"""Enable a disabled extension."""
from . import ExtensionManager, HookExecutor
project_root = _require_specify_project()
manager = ExtensionManager(project_root)
hook_executor = HookExecutor(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None or not isinstance(metadata, dict):
console.print(
f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' "
"not found in registry (corrupted state)"
)
raise typer.Exit(1)
if metadata.get("enabled", True):
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]")
raise typer.Exit(0)
manager.registry.update(extension_id, {"enabled": True})
# Enable hooks in extensions.yml
config = hook_executor.get_project_config()
if "hooks" in config:
for hook_name in config["hooks"]:
for hook in config["hooks"][hook_name]:
if hook.get("extension") == extension_id:
hook["enabled"] = True
hook_executor.save_project_config(config)
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
# #1: regenerate native event config so the enabled extension's events
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)
# Scaffold config templates on enable
try:
deployed, skipped, failed = manager.scaffold_config(extension_id)
except Exception as exc:
console.print(
f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension "
f"'{_escape_markup(str(display_name))}'."
)
console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]")
deployed, skipped, failed = [], [], []
config_home = f".specify/extensions/{_escape_markup(str(extension_id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
@extension_app.command("disable")
def extension_disable(
extension: str = typer.Argument(help="Extension ID or name to disable"),
):
"""Disable an extension without removing it."""
from . import ExtensionManager, HookExecutor
project_root = _require_specify_project()
manager = ExtensionManager(project_root)
hook_executor = HookExecutor(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None or not isinstance(metadata, dict):
console.print(
f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' "
"not found in registry (corrupted state)"
)
raise typer.Exit(1)
if not metadata.get("enabled", True):
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already disabled[/yellow]")
raise typer.Exit(0)
manager.registry.update(extension_id, {"enabled": False})
# Disable hooks in extensions.yml
config = hook_executor.get_project_config()
if "hooks" in config:
for hook_name in config["hooks"]:
for hook in config["hooks"][hook_name]:
if hook.get("extension") == extension_id:
hook["enabled"] = False
hook_executor.save_project_config(config)
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' disabled")
console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
# #1: regenerate native event config so the disabled extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
@extension_app.command("set-priority")
def extension_set_priority(
extension: str = typer.Argument(help="Extension ID or name"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed extension."""
from . import ExtensionManager
project_root = _require_specify_project()
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
# Get current metadata
metadata = manager.registry.get(extension_id)
if metadata is None or not isinstance(metadata, dict):
console.print(
f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' "
"not found in registry (corrupted state)"
)
raise typer.Exit(1)
from . import normalize_priority
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
raise typer.Exit(0)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(extension_id, {"priority": priority})
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' priority changed: {old_priority} → {priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
def register(app: typer.Typer) -> None:
"""Attach the extension command group to the root Typer app."""
app.add_typer(extension_app, name="extension")