mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
feat: Integration catalog — discovery, versioning, and community distribution (#2130)
* Initial plan * feat: add integration catalog system with catalog files, IntegrationCatalog class, list --catalog flag, upgrade command, integration.yml descriptor, and tests Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bbcd44e8-c69c-4735-adc1-bdf1ce109184 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: address PR review feedback - Replace empty except with cache cleanup in _fetch_single_catalog - Log teardown failure warning instead of silent pass in upgrade - Validate catalog_data and integrations are dicts before use - Catch OSError/UnicodeError in IntegrationDescriptor._load - Add isinstance checks for integration/requires/provides/commands - Enforce semver (X.Y.Z) instead of PEP 440 for descriptor versions - Fix docstring and CONTRIBUTING.md to match actual block-on-modified behavior - Restore old manifest on upgrade failure for transactional safety * refactor: address second round of PR review feedback - Remove dead cache_file/cache_metadata_file attributes from IntegrationCatalog - Deduplicate non-default catalog warning (show once per process) - Anchor version regex to reject partial matches like 1.0.0beta - Fix 'Preserved modified' message to 'Skipped' for accuracy - Make upgrade transactional: install new files first, then remove stale old-only files, so a failed setup leaves old integration intact - Update CONTRIBUTING.md: speckit_version validates presence only * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address third round of PR review feedback - Fix CONTRIBUTING.md JSON examples to show full catalog structure with schema_version and integrations wrapper - Wrap cache writes in try/except OSError for read-only project dirs - Validate _load_catalog_config YAML root is a dict - Skip non-dict integ_data entries in merged catalog - Normalize tags to list-of-strings before filtering/searching - Add path traversal containment check for stale file deletion - Clarify docstring: lower numeric priority = higher precedence * fix: address fourth round of PR review feedback - Remove unused _write_catalog helper from test file - Fix comment: tests use monkeypatched urlopen, not file:// URLs - Wrap cache unlink calls in OSError handler - Add explicit encoding='utf-8' to all cache read_text/write_text calls - Restore packaging.version.Version for descriptor version validation to align with extension/preset validators - Add missing goose entry to integrations/catalog.json * fix: remove unused Path import, add comment to empty except * fix: validate descriptor root is dict, add shared infra to upgrade - Add isinstance(self.data, dict) check at start of _validate() so non-mapping YAML roots raise IntegrationDescriptorError - Run _install_shared_infra() and ensure_executable_scripts() in upgrade command to match install/switch behavior * fix: address sixth round of PR review feedback - Validate integration.id/name/version/description are strings - Catch TypeError in pkg_version.Version() for non-string versions - Swap validation order: check catalogs type before emptiness - Isolate TestActiveCatalogs from user ~/.specify/ via monkeypatch * fix: address seventh round of PR review feedback - Update docs: version field uses PEP 440, not semver - Harden search() against non-string author/name/description fields - Validate requires.speckit_version is a non-empty string - Validate command name/file are non-empty strings, file is safe relative path - Handle stale symlinks in upgrade cleanup - Document catalog configuration stack in README.md * fix: validate script entries, remove destructive teardown from upgrade rollback - Validate provides.scripts entries are non-empty strings with safe relative paths - Remove teardown from upgrade rollback since setup overwrites in-place — teardown would delete files that were working before the upgrade * fix: use consistent resolved root for stale-file cleanup paths * fix: validate redirect URL and reject drive-qualified paths - Validate final URL after redirects with _validate_catalog_url() - Reject paths with Path.drive or Path.anchor for Windows safety - Update FakeResponse mocks with geturl() method * fix: fix docstring backticks, assert file modification in upgrade tests * docs: clarify directory naming convention for hyphenated integration keys * fix: correct key type hint, isolate all catalog tests from env - Fix key parameter type to str | None (defaults to None) - Add HOME/USERPROFILE monkeypatch and clear SPECKIT_INTEGRATION_CATALOG_URL in all TestCatalogFetch tests for full environment isolation * fix: neutralize catalog table title, handle non-dict cache metadata * fix: validate requires.tools entries in descriptor * fix: show discovery-only status, clear metadata files in clear_cache * fix: catch OSError/UnicodeError in cache read path * refactor: reuse IntegrationManifest.uninstall for stale-file cleanup * fix: normalize null tools to empty list in descriptor accessor --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1775,7 +1775,9 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
|
||||
|
||||
|
||||
@integration_app.command("list")
|
||||
def integration_list():
|
||||
def integration_list(
|
||||
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
|
||||
):
|
||||
"""List available integrations and installed status."""
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
|
||||
@@ -1790,6 +1792,50 @@ def integration_list():
|
||||
current = _read_integration_json(project_root)
|
||||
installed_key = current.get("integration")
|
||||
|
||||
if catalog:
|
||||
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
|
||||
|
||||
ic = IntegrationCatalog(project_root)
|
||||
try:
|
||||
entries = ic.search()
|
||||
except IntegrationCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not entries:
|
||||
console.print("[yellow]No integrations found in catalog.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Integration Catalog")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Version")
|
||||
table.add_column("Source")
|
||||
table.add_column("Status")
|
||||
|
||||
for entry in sorted(entries, key=lambda e: e["id"]):
|
||||
eid = entry["id"]
|
||||
cat_name = entry.get("_catalog_name", "")
|
||||
install_allowed = entry.get("_install_allowed", True)
|
||||
if eid == installed_key:
|
||||
status = "[green]installed[/green]"
|
||||
elif eid in INTEGRATION_REGISTRY:
|
||||
status = "built-in"
|
||||
elif install_allowed is False:
|
||||
status = "discovery-only"
|
||||
else:
|
||||
status = ""
|
||||
table.add_row(
|
||||
eid,
|
||||
entry.get("name", eid),
|
||||
entry.get("version", ""),
|
||||
cat_name,
|
||||
status,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
return
|
||||
|
||||
table = Table(title="AI Agent Integrations")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Name")
|
||||
@@ -2176,6 +2222,120 @@ def integration_switch(
|
||||
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
||||
|
||||
|
||||
@integration_app.command("upgrade")
|
||||
def integration_upgrade(
|
||||
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||
):
|
||||
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||
|
||||
Compares manifest hashes to detect locally modified files and
|
||||
blocks the upgrade unless --force is used.
|
||||
"""
|
||||
from .integrations import get_integration
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
current = _read_integration_json(project_root)
|
||||
installed_key = current.get("integration")
|
||||
|
||||
if key is None:
|
||||
if not installed_key:
|
||||
console.print("[yellow]No integration is currently installed.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
key = installed_key
|
||||
|
||||
if installed_key and installed_key != key:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')."
|
||||
)
|
||||
console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
integration = get_integration(key)
|
||||
if integration is None:
|
||||
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
if not manifest_path.exists():
|
||||
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
|
||||
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
old_manifest = IntegrationManifest.load(key, project_root)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Detect modified files via manifest hashes
|
||||
modified = old_manifest.check_modified()
|
||||
if modified and not force:
|
||||
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
|
||||
for rel in modified:
|
||||
console.print(f" {rel}")
|
||||
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||
# _install_shared_infra merges missing files without overwriting).
|
||||
_install_shared_infra(project_root, selected_script)
|
||||
if os.name != "nt":
|
||||
ensure_executable_scripts(project_root)
|
||||
|
||||
# Phase 1: Install new files (overwrites existing; old-only files remain)
|
||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
|
||||
|
||||
parsed_options: dict[str, Any] | None = None
|
||||
if integration_options:
|
||||
parsed_options = _parse_integration_options(integration, integration_options)
|
||||
|
||||
try:
|
||||
integration.setup(
|
||||
project_root,
|
||||
new_manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
)
|
||||
new_manifest.save()
|
||||
_write_integration_json(project_root, key, selected_script)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
except Exception as exc:
|
||||
# Don't teardown — setup overwrites in-place, so teardown would
|
||||
# delete files that were working before the upgrade. Just report.
|
||||
console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}")
|
||||
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Phase 2: Remove stale files from old manifest that are not in the new one
|
||||
old_files = old_manifest.files
|
||||
new_files = new_manifest.files
|
||||
stale_keys = set(old_files) - set(new_files)
|
||||
if stale_keys:
|
||||
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
||||
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
||||
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
||||
if stale_removed:
|
||||
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||
|
||||
name = (integration.config or {}).get("name", key)
|
||||
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|
||||
|
||||
|
||||
# ===== Preset Commands =====
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user