Skip to content

Commit 5391d77

Browse files
Copilotmnriem
andauthored
Add specify integration subcommand (list, install, uninstall, switch)
Implements the `specify integration` subcommand group for managing integrations in existing projects after initial setup: - `specify integration list` — shows available integrations and installed status - `specify integration install <key>` — installs an integration into existing project - `specify integration uninstall [key]` — hash-safe removal preserving modified files - `specify integration switch <target>` — uninstalls current, installs target Follows the established `specify <noun> <verb>` CLI pattern used by extensions and presets. Shared infrastructure (scripts, templates) is preserved during uninstall and switch operations. Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1cca6c84-3e12-465d-88b8-a646d3504f63 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
1 parent f81490c commit 5391d77

2 files changed

Lines changed: 759 additions & 0 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,368 @@ def get_speckit_version() -> str:
14861486
return "unknown"
14871487

14881488

1489+
# ===== Integration Commands =====
1490+
1491+
integration_app = typer.Typer(
1492+
name="integration",
1493+
help="Manage AI agent integrations",
1494+
add_completion=False,
1495+
)
1496+
app.add_typer(integration_app, name="integration")
1497+
1498+
1499+
INTEGRATION_JSON = ".specify/integration.json"
1500+
1501+
1502+
def _read_integration_json(project_root: Path) -> dict[str, Any]:
1503+
"""Load ``.specify/integration.json``. Returns ``{}`` on missing/corrupt."""
1504+
path = project_root / INTEGRATION_JSON
1505+
if not path.exists():
1506+
return {}
1507+
try:
1508+
return json.loads(path.read_text(encoding="utf-8"))
1509+
except (json.JSONDecodeError, OSError):
1510+
return {}
1511+
1512+
1513+
def _write_integration_json(
1514+
project_root: Path,
1515+
integration_key: str,
1516+
script_type: str,
1517+
) -> None:
1518+
"""Write ``.specify/integration.json`` for *integration_key*."""
1519+
script_ext = "sh" if script_type == "sh" else "ps1"
1520+
dest = project_root / INTEGRATION_JSON
1521+
dest.parent.mkdir(parents=True, exist_ok=True)
1522+
dest.write_text(json.dumps({
1523+
"integration": integration_key,
1524+
"version": get_speckit_version(),
1525+
"scripts": {
1526+
"update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}",
1527+
},
1528+
}, indent=2) + "\n", encoding="utf-8")
1529+
1530+
1531+
def _remove_integration_json(project_root: Path) -> None:
1532+
"""Remove ``.specify/integration.json`` if it exists."""
1533+
path = project_root / INTEGRATION_JSON
1534+
if path.exists():
1535+
path.unlink()
1536+
1537+
1538+
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
1539+
"""Resolve the script type from the CLI flag or init-options.json."""
1540+
if script_type:
1541+
return script_type
1542+
opts = load_init_options(project_root)
1543+
saved = opts.get("script")
1544+
if saved:
1545+
return saved
1546+
return "ps" if os.name == "nt" else "sh"
1547+
1548+
1549+
@integration_app.command("list")
1550+
def integration_list():
1551+
"""List available integrations and installed status."""
1552+
from .integrations import INTEGRATION_REGISTRY
1553+
1554+
project_root = Path.cwd()
1555+
1556+
specify_dir = project_root / ".specify"
1557+
if not specify_dir.exists():
1558+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1559+
console.print("Run this command from a spec-kit project root")
1560+
raise typer.Exit(1)
1561+
1562+
current = _read_integration_json(project_root)
1563+
installed_key = current.get("integration")
1564+
1565+
table = Table(title="AI Agent Integrations")
1566+
table.add_column("Key", style="cyan")
1567+
table.add_column("Name")
1568+
table.add_column("Status")
1569+
table.add_column("CLI Required")
1570+
1571+
for key in sorted(INTEGRATION_REGISTRY.keys()):
1572+
integration = INTEGRATION_REGISTRY[key]
1573+
cfg = integration.config or {}
1574+
name = cfg.get("name", key)
1575+
requires_cli = cfg.get("requires_cli", False)
1576+
1577+
if key == installed_key:
1578+
status = "[green]installed[/green]"
1579+
else:
1580+
status = ""
1581+
1582+
cli_req = "yes" if requires_cli else "no (IDE)"
1583+
table.add_row(key, name, status, cli_req)
1584+
1585+
console.print(table)
1586+
1587+
if installed_key:
1588+
console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]")
1589+
else:
1590+
console.print("\n[yellow]No integration currently installed.[/yellow]")
1591+
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
1592+
1593+
1594+
@integration_app.command("install")
1595+
def integration_install(
1596+
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
1597+
script: str = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
1598+
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
1599+
force: bool = typer.Option(False, "--force", help="Overwrite existing integration without uninstalling first"),
1600+
):
1601+
"""Install an integration into an existing project."""
1602+
from .integrations import INTEGRATION_REGISTRY, get_integration
1603+
from .integrations.manifest import IntegrationManifest
1604+
1605+
project_root = Path.cwd()
1606+
1607+
specify_dir = project_root / ".specify"
1608+
if not specify_dir.exists():
1609+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1610+
console.print("Run this command from a spec-kit project root")
1611+
raise typer.Exit(1)
1612+
1613+
integration = get_integration(key)
1614+
if integration is None:
1615+
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
1616+
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
1617+
console.print(f"Available integrations: {available}")
1618+
raise typer.Exit(1)
1619+
1620+
current = _read_integration_json(project_root)
1621+
installed_key = current.get("integration")
1622+
1623+
if installed_key and installed_key == key and not force:
1624+
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
1625+
console.print("Use [cyan]--force[/cyan] to reinstall, or [cyan]specify integration switch <target>[/cyan] to change.")
1626+
raise typer.Exit(0)
1627+
1628+
if installed_key and installed_key != key and not force:
1629+
console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.")
1630+
console.print(f"Use [cyan]specify integration switch {key}[/cyan] to switch, or [cyan]--force[/cyan] to overwrite.")
1631+
raise typer.Exit(1)
1632+
1633+
selected_script = _resolve_script_type(project_root, script)
1634+
1635+
manifest = IntegrationManifest(
1636+
integration.key, project_root, version=get_speckit_version()
1637+
)
1638+
1639+
# Build parsed options from --integration-options
1640+
parsed_options: dict[str, Any] | None = None
1641+
if integration_options:
1642+
parsed_options = _parse_integration_options(integration, integration_options)
1643+
1644+
try:
1645+
integration.setup(
1646+
project_root, manifest,
1647+
parsed_options=parsed_options,
1648+
script_type=selected_script,
1649+
raw_options=integration_options,
1650+
)
1651+
manifest.save()
1652+
_write_integration_json(project_root, integration.key, selected_script)
1653+
1654+
# Update init-options.json to reflect the new integration
1655+
opts = load_init_options(project_root)
1656+
opts["integration"] = integration.key
1657+
opts["ai"] = integration.key
1658+
from .integrations.base import SkillsIntegration
1659+
if isinstance(integration, SkillsIntegration):
1660+
opts["ai_skills"] = True
1661+
save_init_options(project_root, opts)
1662+
1663+
except Exception as e:
1664+
console.print(f"[red]Error:[/red] Failed to install integration: {e}")
1665+
raise typer.Exit(1)
1666+
1667+
name = (integration.config or {}).get("name", key)
1668+
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
1669+
1670+
1671+
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any]:
1672+
"""Parse --integration-options string into a dict matching the integration's declared options."""
1673+
import shlex
1674+
parsed: dict[str, Any] = {}
1675+
tokens = shlex.split(raw_options)
1676+
declared = {opt.name.lstrip("-"): opt for opt in integration.options()}
1677+
i = 0
1678+
while i < len(tokens):
1679+
token = tokens[i]
1680+
name = token.lstrip("-")
1681+
opt = declared.get(name)
1682+
if opt and opt.is_flag:
1683+
parsed[name.replace("-", "_")] = True
1684+
i += 1
1685+
elif opt and i + 1 < len(tokens):
1686+
parsed[name.replace("-", "_")] = tokens[i + 1]
1687+
i += 2
1688+
else:
1689+
i += 1
1690+
return parsed or None
1691+
1692+
1693+
@integration_app.command("uninstall")
1694+
def integration_uninstall(
1695+
key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
1696+
force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
1697+
):
1698+
"""Uninstall an integration, safely preserving modified files."""
1699+
from .integrations import get_integration
1700+
from .integrations.manifest import IntegrationManifest
1701+
1702+
project_root = Path.cwd()
1703+
1704+
specify_dir = project_root / ".specify"
1705+
if not specify_dir.exists():
1706+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1707+
console.print("Run this command from a spec-kit project root")
1708+
raise typer.Exit(1)
1709+
1710+
current = _read_integration_json(project_root)
1711+
installed_key = current.get("integration")
1712+
1713+
if key is None:
1714+
if not installed_key:
1715+
console.print("[yellow]No integration is currently installed.[/yellow]")
1716+
raise typer.Exit(0)
1717+
key = installed_key
1718+
1719+
if installed_key and installed_key != key:
1720+
console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').")
1721+
raise typer.Exit(1)
1722+
1723+
integration = get_integration(key)
1724+
if integration is None:
1725+
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
1726+
raise typer.Exit(1)
1727+
1728+
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
1729+
if not manifest_path.exists():
1730+
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
1731+
_remove_integration_json(project_root)
1732+
raise typer.Exit(0)
1733+
1734+
manifest = IntegrationManifest.load(key, project_root)
1735+
1736+
removed, skipped = integration.teardown(project_root, manifest, force=force)
1737+
1738+
_remove_integration_json(project_root)
1739+
1740+
# Update init-options.json to clear the integration
1741+
opts = load_init_options(project_root)
1742+
if opts.get("integration") == key:
1743+
opts.pop("integration", None)
1744+
opts.pop("ai", None)
1745+
opts.pop("ai_skills", None)
1746+
save_init_options(project_root, opts)
1747+
1748+
name = (integration.config or {}).get("name", key)
1749+
console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
1750+
if removed:
1751+
console.print(f" Removed {len(removed)} file(s)")
1752+
if skipped:
1753+
console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:")
1754+
for path in skipped:
1755+
rel = path.relative_to(project_root) if path.is_absolute() else path
1756+
console.print(f" {rel}")
1757+
1758+
1759+
@integration_app.command("switch")
1760+
def integration_switch(
1761+
target: str = typer.Argument(help="Integration key to switch to"),
1762+
script: str = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
1763+
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
1764+
integration_options: str = typer.Option(None, "--integration-options", help='Options for the target integration'),
1765+
):
1766+
"""Switch from the current integration to a different one."""
1767+
from .integrations import INTEGRATION_REGISTRY, get_integration
1768+
from .integrations.manifest import IntegrationManifest
1769+
1770+
project_root = Path.cwd()
1771+
1772+
specify_dir = project_root / ".specify"
1773+
if not specify_dir.exists():
1774+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1775+
console.print("Run this command from a spec-kit project root")
1776+
raise typer.Exit(1)
1777+
1778+
target_integration = get_integration(target)
1779+
if target_integration is None:
1780+
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
1781+
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
1782+
console.print(f"Available integrations: {available}")
1783+
raise typer.Exit(1)
1784+
1785+
current = _read_integration_json(project_root)
1786+
installed_key = current.get("integration")
1787+
1788+
if installed_key == target:
1789+
console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]")
1790+
raise typer.Exit(0)
1791+
1792+
selected_script = _resolve_script_type(project_root, script)
1793+
1794+
# Phase 1: Uninstall current integration (if any)
1795+
if installed_key:
1796+
current_integration = get_integration(installed_key)
1797+
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
1798+
1799+
if current_integration and manifest_path.exists():
1800+
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
1801+
old_manifest = IntegrationManifest.load(installed_key, project_root)
1802+
removed, skipped = current_integration.teardown(
1803+
project_root, old_manifest, force=force,
1804+
)
1805+
if removed:
1806+
console.print(f" Removed {len(removed)} file(s)")
1807+
if skipped:
1808+
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
1809+
else:
1810+
console.print(f"[dim]No manifest for '{installed_key}' — skipping uninstall phase[/dim]")
1811+
1812+
# Phase 2: Install target integration
1813+
console.print(f"Installing integration: [cyan]{target}[/cyan]")
1814+
manifest = IntegrationManifest(
1815+
target_integration.key, project_root, version=get_speckit_version()
1816+
)
1817+
1818+
parsed_options: dict[str, Any] | None = None
1819+
if integration_options:
1820+
parsed_options = _parse_integration_options(target_integration, integration_options)
1821+
1822+
try:
1823+
target_integration.setup(
1824+
project_root, manifest,
1825+
parsed_options=parsed_options,
1826+
script_type=selected_script,
1827+
raw_options=integration_options,
1828+
)
1829+
manifest.save()
1830+
_write_integration_json(project_root, target_integration.key, selected_script)
1831+
1832+
# Update init-options.json
1833+
opts = load_init_options(project_root)
1834+
opts["integration"] = target_integration.key
1835+
opts["ai"] = target_integration.key
1836+
from .integrations.base import SkillsIntegration
1837+
if isinstance(target_integration, SkillsIntegration):
1838+
opts["ai_skills"] = True
1839+
else:
1840+
opts.pop("ai_skills", None)
1841+
save_init_options(project_root, opts)
1842+
1843+
except Exception as e:
1844+
console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}")
1845+
raise typer.Exit(1)
1846+
1847+
name = (target_integration.config or {}).get("name", target)
1848+
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
1849+
1850+
14891851
# ===== Preset Commands =====
14901852

14911853

0 commit comments

Comments
 (0)