@@ -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