Skip to content

Commit bae4448

Browse files
committed
feat: interactive approval flow for community extensions
1 parent 3e69233 commit bae4448

4 files changed

Lines changed: 373 additions & 62 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 88 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,7 @@ def catalog_add(
879879
})
880880

881881
config["catalogs"] = catalogs
882-
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
882+
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
883883

884884
install_label = "install allowed" if install_allowed else "discovery only"
885885
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
@@ -919,7 +919,7 @@ def catalog_remove(
919919
raise typer.Exit(1)
920920

921921
config["catalogs"] = catalogs
922-
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
922+
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
923923

924924
console.print(f"[green]✓[/green] Removed catalog '{name}'")
925925
if not catalogs:
@@ -987,8 +987,8 @@ def extension_add(
987987
raise typer.Exit(0)
988988

989989
try:
990-
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
991-
if dev:
990+
if dev:
991+
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
992992
# Install from local directory
993993
source_path = Path(extension).expanduser().resolve()
994994
if not source_path.exists():
@@ -1010,12 +1010,13 @@ def extension_add(
10101010
force=force
10111011
)
10121012

1013-
elif from_url:
1014-
# Install from URL (ZIP file)
1015-
import urllib.error
1013+
elif from_url:
1014+
# Install from URL (ZIP file)
1015+
import urllib.error
10161016

1017-
console.print(f"Downloading from {safe_url}...")
1017+
console.print(f"Downloading from {safe_url}...")
10181018

1019+
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
10191020
# Download ZIP to temp location
10201021
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
10211022
download_dir.mkdir(parents=True, exist_ok=True)
@@ -1038,66 +1039,86 @@ def extension_add(
10381039
if zip_path.exists():
10391040
zip_path.unlink()
10401041

1041-
else:
1042-
# Try bundled extensions first (shipped with spec-kit)
1043-
bundled_path = _locate_bundled_extension(extension)
1044-
if bundled_path is not None:
1042+
else:
1043+
# Try bundled extensions first (shipped with spec-kit)
1044+
bundled_path = _locate_bundled_extension(extension)
1045+
if bundled_path is not None:
1046+
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
10451047
manifest = manager.install_from_directory(
10461048
bundled_path, speckit_version, priority=priority, force=force
10471049
)
1048-
else:
1049-
# Install from catalog (also resolves display names to IDs)
1050-
catalog = ExtensionCatalog(project_root)
1050+
else:
1051+
# Install from catalog (also resolves display names to IDs)
1052+
catalog = ExtensionCatalog(project_root)
10511053

1052-
# Check if extension exists in catalog (supports both ID and display name)
1053-
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
1054-
if catalog_error:
1055-
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
1056-
raise typer.Exit(1)
1057-
if not ext_info:
1058-
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
1059-
console.print("\nSearch available extensions:")
1060-
console.print(" specify extension search")
1061-
raise typer.Exit(1)
1054+
# Check if extension exists in catalog (supports both ID and display name)
1055+
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
1056+
if catalog_error:
1057+
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
1058+
raise typer.Exit(1)
1059+
if not ext_info:
1060+
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
1061+
console.print("\nSearch available extensions:")
1062+
console.print(" specify extension search")
1063+
raise typer.Exit(1)
10621064

1063-
# If catalog resolved a display name to an ID, check bundled again
1064-
resolved_id = ext_info['id']
1065-
if resolved_id != extension:
1066-
bundled_path = _locate_bundled_extension(resolved_id)
1067-
if bundled_path is not None:
1065+
# If catalog resolved a display name to an ID, check bundled again
1066+
resolved_id = ext_info['id']
1067+
if resolved_id != extension:
1068+
bundled_path = _locate_bundled_extension(resolved_id)
1069+
if bundled_path is not None:
1070+
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
10681071
manifest = manager.install_from_directory(
10691072
bundled_path, speckit_version, priority=priority, force=force
10701073
)
10711074

1072-
if bundled_path is None:
1073-
# Bundled extensions without a download URL must come from the local package
1074-
if ext_info.get("bundled") and not ext_info.get("download_url"):
1075-
console.print(
1076-
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
1077-
f"but could not be found in the installed package."
1078-
)
1079-
console.print(
1080-
"\nThis usually means the spec-kit installation is incomplete or corrupted."
1081-
)
1082-
console.print("Try reinstalling spec-kit:")
1083-
console.print(f" {REINSTALL_COMMAND}")
1084-
raise typer.Exit(1)
1085-
1086-
# Enforce install_allowed policy
1087-
if not ext_info.get("_install_allowed", True):
1088-
catalog_name = ext_info.get("_catalog_name", "community")
1089-
console.print(
1090-
f"[red]Error:[/red] '{extension}' is available in the "
1091-
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
1092-
)
1093-
console.print(
1094-
f"\nTo enable installation, add '{extension}' to an approved catalog "
1095-
f"(install_allowed: true) in .specify/extension-catalogs.yml."
1075+
if bundled_path is None:
1076+
# Bundled extensions without a download URL must come from the local package
1077+
if ext_info.get("bundled") and not ext_info.get("download_url"):
1078+
console.print(
1079+
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
1080+
f"but could not be found in the installed package."
1081+
)
1082+
console.print(
1083+
"\nThis usually means the spec-kit installation is incomplete or corrupted."
1084+
)
1085+
console.print("Try reinstalling spec-kit:")
1086+
console.print(f" {REINSTALL_COMMAND}")
1087+
raise typer.Exit(1)
1088+
1089+
# Enforce install_allowed policy
1090+
if not ext_info.get("_install_allowed", True):
1091+
catalog_name = ext_info.get("_catalog_name", "community")
1092+
console.print()
1093+
console.print(
1094+
Panel(
1095+
f"[bold]'{ext_info['name']}' is available in the '{catalog_name}' catalog "
1096+
f"but installation is not allowed from that catalog.[/bold]\n\n"
1097+
f"Approve installation from '{catalog_name}' for this project?\n"
1098+
"This will update .specify/extension-catalogs.yml so future installs "
1099+
"from that catalog are allowed.",
1100+
title="[bold yellow]Catalog Approval Required[/bold yellow]",
1101+
border_style="yellow",
1102+
padding=(1, 2),
10961103
)
1097-
raise typer.Exit(1)
1104+
)
1105+
console.print()
1106+
try:
1107+
confirm = typer.confirm("Approve catalog and continue?", default=False)
1108+
except (typer.Abort, KeyboardInterrupt):
1109+
console.print("Cancelled")
1110+
raise typer.Exit(0)
1111+
if not confirm:
1112+
console.print("Cancelled")
1113+
raise typer.Exit(0)
1114+
approved_catalog = catalog.approve_catalog_install(catalog_name)
1115+
console.print(
1116+
f"[green]✓[/green] Approved catalog '[bold]{approved_catalog.name}[/bold]' for installation"
1117+
)
10981118

1099-
# Download extension ZIP (use resolved ID, not original argument which may be display name)
1100-
extension_id = ext_info['id']
1119+
# Download extension ZIP (use resolved ID, not original argument which may be display name)
1120+
extension_id = ext_info['id']
1121+
with console.status(f"[cyan]Installing extension: {ext_info['name']}[/cyan]"):
11011122
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
11021123
zip_path = catalog.download_extension(extension_id)
11031124

@@ -1286,8 +1307,9 @@ def extension_search(
12861307
else:
12871308
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
12881309
console.print(
1289-
f" Add to an approved catalog with install_allowed: true, "
1290-
f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
1310+
f" Run [cyan]specify extension add {ext['id']}[/cyan] to approve "
1311+
f"the catalog and install, or use a ZIP URL: "
1312+
f"specify extension add {ext['id']} --from <zip-url>"
12911313
)
12921314
console.print()
12931315

@@ -1486,8 +1508,13 @@ def _print_extension_info(ext_info: dict, manager):
14861508
console.print("[yellow]Not installed[/yellow]")
14871509
console.print(
14881510
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
1489-
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
1490-
f"with install_allowed: true to enable installation."
1511+
f"but installation is not currently allowed from that catalog."
1512+
)
1513+
console.print(
1514+
f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}"
1515+
)
1516+
console.print(
1517+
"[dim]You will be prompted to approve the catalog before installation proceeds.[/dim]"
14911518
)
14921519

14931520

src/specify_cli/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
from specify_cli import main
3+
4+
if __name__ == "__main__":
5+
sys.exit(main())

src/specify_cli/extensions.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1275,7 +1275,7 @@ def check_compatibility(
12751275
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
12761276
try:
12771277
specifier = SpecifierSet(required)
1278-
if current not in specifier:
1278+
if not specifier.contains(current, prereleases=True):
12791279
raise CompatibilityError(
12801280
f"Extension requires spec-kit {required}, "
12811281
f"but {speckit_version} is installed.\n"
@@ -2100,6 +2100,63 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
21002100
),
21012101
]
21022102

2103+
def _catalog_entry_to_dict(self, entry: CatalogEntry) -> Dict[str, Any]:
2104+
"""Serialize a catalog entry back to YAML config shape."""
2105+
return {
2106+
"name": entry.name,
2107+
"url": entry.url,
2108+
"priority": entry.priority,
2109+
"install_allowed": entry.install_allowed,
2110+
"description": entry.description,
2111+
}
2112+
2113+
def approve_catalog_install(self, catalog_name: str) -> CatalogEntry:
2114+
"""Persist install permission for a catalog while preserving the stack."""
2115+
active_catalogs = self.get_active_catalogs()
2116+
updated_catalogs: List[Dict[str, Any]] = []
2117+
approved_entry: Optional[CatalogEntry] = None
2118+
2119+
for entry in active_catalogs:
2120+
if entry.name == catalog_name:
2121+
entry = self._entry(
2122+
url=entry.url,
2123+
name=entry.name,
2124+
priority=entry.priority,
2125+
install_allowed=True,
2126+
description=entry.description,
2127+
)
2128+
approved_entry = entry
2129+
updated_catalogs.append(self._catalog_entry_to_dict(entry))
2130+
2131+
if approved_entry is None:
2132+
raise ValidationError(
2133+
f"Catalog '{catalog_name}' is not active and cannot be approved"
2134+
)
2135+
2136+
project_root = self.project_root.resolve()
2137+
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
2138+
resolved_parent = config_path.parent.resolve()
2139+
if not resolved_parent.is_relative_to(project_root):
2140+
raise ValidationError(
2141+
"Refusing to write catalog config outside the project root"
2142+
)
2143+
if config_path.exists() and config_path.is_symlink():
2144+
raise ValidationError(
2145+
f"Refusing to write catalog config via symlink: {config_path}"
2146+
)
2147+
2148+
config_path.parent.mkdir(parents=True, exist_ok=True)
2149+
config_path.write_text(
2150+
yaml.safe_dump(
2151+
{"catalogs": updated_catalogs},
2152+
default_flow_style=False,
2153+
sort_keys=False,
2154+
allow_unicode=True,
2155+
),
2156+
encoding="utf-8",
2157+
)
2158+
return approved_entry
2159+
21032160
def get_catalog_url(self) -> str:
21042161
"""Get the primary catalog URL.
21052162

0 commit comments

Comments
 (0)