"""Base classes for AI-assistant integrations. Provides: - ``IntegrationOption`` — declares a CLI option an integration accepts. - ``IntegrationBase`` — abstract base every integration must implement. - ``MarkdownIntegration`` — concrete base for standard Markdown-format integrations (the common case — subclass, set three class attrs, done). - ``TomlIntegration`` — concrete base for TOML-format integrations (Gemini, Tabnine — subclass, set three class attrs, done). """ from __future__ import annotations import re import shutil from abc import ABC from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .manifest import IntegrationManifest # --------------------------------------------------------------------------- # IntegrationOption # --------------------------------------------------------------------------- @dataclass(frozen=True) class IntegrationOption: """Declares an option that an integration accepts via ``--integration-options``. Attributes: name: The flag name (e.g. ``"--commands-dir"``). is_flag: ``True`` for boolean flags (``--skills``). required: ``True`` if the option must be supplied. default: Default value when not supplied (``None`` → no default). help: One-line description shown in ``specify integrate info``. """ name: str is_flag: bool = False required: bool = False default: Any = None help: str = "" # --------------------------------------------------------------------------- # IntegrationBase — abstract base class # --------------------------------------------------------------------------- class IntegrationBase(ABC): """Abstract base class every integration must implement. Subclasses must set the following class-level attributes: * ``key`` — unique identifier, matches actual CLI tool name * ``config`` — dict compatible with ``AGENT_CONFIG`` entries * ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS`` And may optionally set: * ``context_file`` — path (relative to project root) of the agent context/instructions file (e.g. ``"CLAUDE.md"``) """ # -- Must be set by every subclass ------------------------------------ key: str = "" """Unique integration key — should match the actual CLI tool name.""" config: dict[str, Any] | None = None """Metadata dict matching the ``AGENT_CONFIG`` shape.""" registrar_config: dict[str, Any] | None = None """Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape.""" # -- Optional --------------------------------------------------------- context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" # -- Public API ------------------------------------------------------- @classmethod def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] # -- Primitives — building blocks for setup() ------------------------- def shared_commands_dir(self) -> Path | None: """Return path to the shared command templates directory. Checks ``core_pack/commands/`` (wheel install) first, then ``templates/commands/`` (source checkout). Returns ``None`` if neither exists. """ import inspect pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent for candidate in [ pkg_dir / "core_pack" / "commands", pkg_dir.parent.parent / "templates" / "commands", ]: if candidate.is_dir(): return candidate return None def shared_templates_dir(self) -> Path | None: """Return path to the shared page templates directory. Contains ``vscode-settings.json``, ``spec-template.md``, etc. Checks ``core_pack/templates/`` then ``templates/``. """ import inspect pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent for candidate in [ pkg_dir / "core_pack" / "templates", pkg_dir.parent.parent / "templates", ]: if candidate.is_dir(): return candidate return None def list_command_templates(self) -> list[Path]: """Return sorted list of command template files from the shared directory.""" cmd_dir = self.shared_commands_dir() if not cmd_dir or not cmd_dir.is_dir(): return [] return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md") def command_filename(self, template_name: str) -> str: """Return the destination filename for a command template. *template_name* is the stem of the source file (e.g. ``"plan"``). Default: ``speckit.{template_name}.md``. Subclasses override to change the extension or naming convention. """ return f"speckit.{template_name}.md" def commands_dest(self, project_root: Path) -> Path: """Return the absolute path to the commands output directory. Derived from ``config["folder"]`` and ``config["commands_subdir"]``. Raises ``ValueError`` if ``config`` or ``folder`` is missing. """ if not self.config: raise ValueError( f"{type(self).__name__}.config is not set; integration " "subclasses must define a non-empty 'config' mapping." ) folder = self.config.get("folder") if not folder: raise ValueError( f"{type(self).__name__}.config is missing required 'folder' entry." ) subdir = self.config.get("commands_subdir", "commands") return project_root / folder / subdir # -- File operations — granular primitives for setup() ---------------- @staticmethod def copy_command_to_directory( src: Path, dest_dir: Path, filename: str, ) -> Path: """Copy a command template to *dest_dir* with the given *filename*. Creates *dest_dir* if needed. Returns the absolute path of the written file. The caller can post-process the file before recording it in the manifest. """ dest_dir.mkdir(parents=True, exist_ok=True) dst = dest_dir / filename shutil.copy2(src, dst) return dst @staticmethod def record_file_in_manifest( file_path: Path, project_root: Path, manifest: IntegrationManifest, ) -> None: """Hash *file_path* and record it in *manifest*. *file_path* must be inside *project_root*. """ rel = file_path.resolve().relative_to(project_root.resolve()) manifest.record_existing(rel) @staticmethod def write_file_and_record( content: str, dest: Path, project_root: Path, manifest: IntegrationManifest, ) -> Path: """Write *content* to *dest*, hash it, and record in *manifest*. Creates parent directories as needed. Returns *dest*. """ dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content, encoding="utf-8") rel = dest.resolve().relative_to(project_root.resolve()) manifest.record_existing(rel) return dest def integration_scripts_dir(self) -> Path | None: """Return path to this integration's bundled ``scripts/`` directory. Looks for a ``scripts/`` sibling of the module that defines the concrete subclass (not ``IntegrationBase`` itself). Returns ``None`` if the directory doesn't exist. """ import inspect cls_file = inspect.getfile(type(self)) scripts = Path(cls_file).resolve().parent / "scripts" return scripts if scripts.is_dir() else None def install_scripts( self, project_root: Path, manifest: IntegrationManifest, ) -> list[Path]: """Copy integration-specific scripts into the project. Copies files from this integration's ``scripts/`` directory to ``.specify/integrations//scripts/`` in the project. Shell scripts are made executable. All copied files are recorded in *manifest*. Returns the list of files created. """ scripts_src = self.integration_scripts_dir() if not scripts_src: return [] created: list[Path] = [] scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts" scripts_dest.mkdir(parents=True, exist_ok=True) for src_script in sorted(scripts_src.iterdir()): if not src_script.is_file(): continue dst_script = scripts_dest / src_script.name shutil.copy2(src_script, dst_script) if dst_script.suffix == ".sh": dst_script.chmod(dst_script.stat().st_mode | 0o111) self.record_file_in_manifest(dst_script, project_root, manifest) created.append(dst_script) return created @staticmethod def process_template( content: str, agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", ) -> str: """Process a raw command template into agent-ready content. Performs the same transformations as the release script: 1. Extract ``scripts.`` value from YAML frontmatter 2. Replace ``{SCRIPT}`` with the extracted script command 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter 5. Replace ``{ARGS}`` with *arg_placeholder* 6. Replace ``__AGENT__`` with *agent_name* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ # 1. Extract script command from frontmatter script_command = "" script_pattern = re.compile( rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE ) # Find the scripts: block in_scripts = False for line in content.splitlines(): if line.strip() == "scripts:": in_scripts = True continue if in_scripts and line and not line[0].isspace(): in_scripts = False if in_scripts: m = script_pattern.match(line) if m: script_command = m.group(1).strip() break # 2. Replace {SCRIPT} if script_command: content = content.replace("{SCRIPT}", script_command) # 3. Extract agent_script command agent_script_command = "" in_agent_scripts = False for line in content.splitlines(): if line.strip() == "agent_scripts:": in_agent_scripts = True continue if in_agent_scripts and line and not line[0].isspace(): in_agent_scripts = False if in_agent_scripts: m = script_pattern.match(line) if m: agent_script_command = m.group(1).strip() break if agent_script_command: content = content.replace("{AGENT_SCRIPT}", agent_script_command) # 4. Strip scripts: and agent_scripts: sections from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] in_frontmatter = False skip_section = False dash_count = 0 for line in lines: stripped = line.rstrip("\n\r") if stripped == "---": dash_count += 1 if dash_count == 1: in_frontmatter = True else: in_frontmatter = False skip_section = False output_lines.append(line) continue if in_frontmatter: if stripped in ("scripts:", "agent_scripts:"): skip_section = True continue if skip_section: if line[0:1].isspace(): continue # skip indented content under scripts/agent_scripts skip_section = False output_lines.append(line) content = "".join(output_lines) # 5. Replace {ARGS} content = content.replace("{ARGS}", arg_placeholder) # 6. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) # 7. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar content = CommandRegistrar.rewrite_project_relative_paths(content) return content def setup( self, project_root: Path, manifest: IntegrationManifest, parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: """Install integration command files into *project_root*. Returns the list of files created. Copies raw templates without processing. Integrations that need placeholder replacement (e.g. ``{SCRIPT}``, ``__AGENT__``) should override ``setup()`` and call ``process_template()`` in their own loop — see ``CopilotIntegration`` for an example. """ templates = self.list_command_templates() if not templates: return [] project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( f"manifest.project_root ({manifest.project_root}) does not match " f"project_root ({project_root_resolved})" ) dest = self.commands_dest(project_root).resolve() try: dest.relative_to(project_root_resolved) except ValueError as exc: raise ValueError( f"Integration destination {dest} escapes " f"project root {project_root_resolved}" ) from exc created: list[Path] = [] for src_file in templates: dst_name = self.command_filename(src_file.stem) dst_file = self.copy_command_to_directory(src_file, dest, dst_name) self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) return created def teardown( self, project_root: Path, manifest: IntegrationManifest, *, force: bool = False, ) -> tuple[list[Path], list[Path]]: """Uninstall integration files from *project_root*. Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). Returns ``(removed, skipped)`` file lists. """ return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- def install( self, project_root: Path, manifest: IntegrationManifest, parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: """High-level install — calls ``setup()`` and returns created files.""" return self.setup( project_root, manifest, parsed_options=parsed_options, **opts ) def uninstall( self, project_root: Path, manifest: IntegrationManifest, *, force: bool = False, ) -> tuple[list[Path], list[Path]]: """High-level uninstall — calls ``teardown()``.""" return self.teardown(project_root, manifest, force=force) # --------------------------------------------------------------------------- # MarkdownIntegration — covers ~20 standard agents # --------------------------------------------------------------------------- class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. Subclasses only need to set ``key``, ``config``, ``registrar_config`` (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs integration-specific scripts (``update-context.sh`` / ``.ps1``). """ def setup( self, project_root: Path, manifest: IntegrationManifest, parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: templates = self.list_command_templates() if not templates: return [] project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( f"manifest.project_root ({manifest.project_root}) does not match " f"project_root ({project_root_resolved})" ) dest = self.commands_dest(project_root).resolve() try: dest.relative_to(project_root_resolved) except ValueError as exc: raise ValueError( f"Integration destination {dest} escapes " f"project root {project_root_resolved}" ) from exc dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template(raw, self.key, script_type, arg_placeholder) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest ) created.append(dst_file) created.extend(self.install_scripts(project_root, manifest)) return created # --------------------------------------------------------------------------- # TomlIntegration — TOML-format agents (Gemini, Tabnine) # --------------------------------------------------------------------------- class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. Mirrors ``MarkdownIntegration`` closely: subclasses only need to set ``key``, ``config``, ``registrar_config`` (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to TOML format (``description`` key + ``prompt`` multiline string). """ def command_filename(self, template_name: str) -> str: """TOML commands use ``.toml`` extension.""" return f"speckit.{template_name}.toml" @staticmethod def _extract_description(content: str) -> str: """Extract the ``description`` value from YAML frontmatter. Scans lines between the first pair of ``---`` delimiters for a top-level ``description:`` key. Returns the value (with surrounding quotes stripped) or an empty string if not found. """ in_frontmatter = False for line in content.splitlines(): stripped = line.rstrip("\n\r") if stripped == "---": if not in_frontmatter: in_frontmatter = True continue break # second --- if in_frontmatter and stripped.startswith("description:"): _, _, value = stripped.partition(":") return value.strip().strip('"').strip("'") return "" @staticmethod def _render_toml(description: str, body: str) -> str: """Render a TOML command file from description and body. Uses multiline basic strings (``\"\"\"``) with backslashes escaped, matching the output of the release script. Falls back to multiline literal strings (``'''``) if the body contains ``\"\"\"``, then to an escaped basic string as a last resort. The body is rstrip'd so the closing delimiter appears on the line immediately after the last content line — matching the release script's ``echo "$body"; echo '\"\"\"'`` pattern. """ toml_lines: list[str] = [] if description: desc = description.replace('"', '\\"') toml_lines.append(f'description = "{desc}"') toml_lines.append("") body = body.rstrip("\n") # Escape backslashes for basic multiline strings. escaped = body.replace("\\", "\\\\") if '"""' not in escaped: toml_lines.append('prompt = """') toml_lines.append(escaped) toml_lines.append('"""') elif "'''" not in body: toml_lines.append("prompt = '''") toml_lines.append(body) toml_lines.append("'''") else: escaped_body = ( body.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") ) toml_lines.append(f'prompt = "{escaped_body}"') return "\n".join(toml_lines) + "\n" def setup( self, project_root: Path, manifest: IntegrationManifest, parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: templates = self.list_command_templates() if not templates: return [] project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( f"manifest.project_root ({manifest.project_root}) does not match " f"project_root ({project_root_resolved})" ) dest = self.commands_dest(project_root).resolve() try: dest.relative_to(project_root_resolved) except ValueError as exc: raise ValueError( f"Integration destination {dest} escapes " f"project root {project_root_resolved}" ) from exc dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}" created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template(raw, self.key, script_type, arg_placeholder) toml_content = self._render_toml(description, processed) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( toml_content, dest / dst_name, project_root, manifest ) created.append(dst_file) created.extend(self.install_scripts(project_root, manifest)) return created