"""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). - ``SkillsIntegration`` — concrete base for integrations that install commands as agent skills (``speckit-/SKILL.md`` layout). """ 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. Writes bytes directly to avoid platform newline translation (CRLF on Windows). Any ``\r\n`` sequences in *content* are normalised to ``\n`` before writing. Returns *dest*. """ dest.parent.mkdir(parents=True, exist_ok=True) normalized = content.replace("\r\n", "\n") dest.write_bytes(normalized.encode("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. Parses the YAML frontmatter so block scalar descriptions (``|`` and ``>``) keep their YAML semantics instead of being treated as raw text. """ import yaml frontmatter_text, _ = TomlIntegration._split_frontmatter(content) if not frontmatter_text: return "" try: frontmatter = yaml.safe_load(frontmatter_text) or {} except yaml.YAMLError: return "" if not isinstance(frontmatter, dict): return "" description = frontmatter.get("description", "") if isinstance(description, str): return description return "" @staticmethod def _split_frontmatter(content: str) -> tuple[str, str]: """Split YAML frontmatter from the remaining content. Returns ``("", content)`` when no complete frontmatter block is present. The body is preserved exactly as written so prompt text keeps its intended formatting. """ if not content.startswith("---"): return "", content lines = content.splitlines(keepends=True) if not lines or lines[0].rstrip("\r\n") != "---": return "", content frontmatter_end = -1 for i, line in enumerate(lines[1:], start=1): if line.rstrip("\r\n") == "---": frontmatter_end = i break if frontmatter_end == -1: return "", content frontmatter = "".join(lines[1:frontmatter_end]) body = "".join(lines[frontmatter_end + 1 :]) return frontmatter, body @staticmethod def _render_toml_string(value: str) -> str: """Render *value* as a TOML string literal. Uses a basic string for single-line values, multiline basic strings for values containing newlines, and falls back to a literal string or escaped basic string when delimiters appear in the content. """ if "\n" not in value and "\r" not in value: escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' escaped = value.replace("\\", "\\\\") if '"""' not in escaped: if escaped.endswith('"'): return '"""\n' + escaped + '\\\n"""' return '"""\n' + escaped + '"""' if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" return '"' + ( value.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") ) + '"' @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("\\n")``'d before rendering, so the TOML value preserves content without forcing a trailing newline. As a result, multiline delimiters appear on their own line only when the rendered value itself ends with a newline. """ toml_lines: list[str] = [] if description: toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}") toml_lines.append("") body = body.rstrip("\n") toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(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) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) 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 # --------------------------------------------------------------------------- # SkillsIntegration — skills-format agents (Codex, Kimi, Agy) # --------------------------------------------------------------------------- class SkillsIntegration(IntegrationBase): """Concrete base for integrations that install commands as agent skills. Skills use the ``speckit-/SKILL.md`` directory layout following the `agentskills.io `_ spec. Subclasses set ``key``, ``config``, ``registrar_config`` (and optionally ``context_file``) like any integration. They may also override ``options()`` to declare additional CLI flags (e.g. ``--skills``, ``--migrate-legacy``). ``setup()`` processes each shared command template into a ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ def skills_dest(self, project_root: Path) -> Path: """Return the absolute path to the skills output directory. Derived from ``config["folder"]`` and the configured ``commands_subdir`` (defaults to ``"skills"``). Raises ``ValueError`` when ``config`` or ``folder`` is missing. """ if not self.config: raise ValueError( f"{type(self).__name__}.config is not set." ) 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", "skills") return project_root / folder / subdir def setup( self, project_root: Path, manifest: IntegrationManifest, parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: """Install command templates as agent skills. Creates ``speckit-/SKILL.md`` for each shared command template. Each SKILL.md has normalised frontmatter containing ``name``, ``description``, ``compatibility``, and ``metadata``. """ import yaml 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})" ) skills_dir = self.skills_dest(project_root).resolve() try: skills_dir.relative_to(project_root_resolved) except ValueError as exc: raise ValueError( f"Skills destination {skills_dir} escapes " f"project root {project_root_resolved}" ) from exc 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") # Derive the skill name from the template stem command_name = src_file.stem # e.g. "plan" skill_name = f"speckit-{command_name.replace('.', '-')}" # Parse frontmatter for description frontmatter: dict[str, Any] = {} if raw.startswith("---"): parts = raw.split("---", 2) if len(parts) >= 3: try: fm = yaml.safe_load(parts[1]) if isinstance(fm, dict): frontmatter = fm except yaml.YAMLError: pass # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP # output byte-for-byte (the template body starts with \n after # the closing ---). if processed_body.startswith("---"): parts = processed_body.split("---", 2) if len(parts) >= 3: processed_body = parts[2] # Select description — use the original template description # to stay byte-for-byte identical with release ZIP output. description = frontmatter.get("description", "") if not description: description = f"Spec Kit: {command_name} workflow" # Build SKILL.md with manually formatted frontmatter to match # the release packaging script output exactly (double-quoted # values, no yaml.safe_dump quoting differences). def _quote(v: str) -> str: escaped = v.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' skill_content = ( f"---\n" f"name: {_quote(skill_name)}\n" f"description: {_quote(description)}\n" f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"metadata:\n" f" author: {_quote('github-spec-kit')}\n" f" source: {_quote('templates/commands/' + src_file.name)}\n" f"---\n" f"{processed_body}" ) # Write speckit-/SKILL.md skill_dir = skills_dir / skill_name skill_file = skill_dir / "SKILL.md" dst = self.write_file_and_record( skill_content, skill_file, project_root, manifest ) created.append(dst) created.extend(self.install_scripts(project_root, manifest)) return created