mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
* feat: Stage 2a — CopilotIntegration with shared template primitives - base.py: added granular primitives (shared_commands_dir, shared_templates_dir, list_command_templates, command_filename, commands_dest, copy_command_to_directory, record_file_in_manifest, write_file_and_record, process_template) - CopilotIntegration: uses primitives to produce .agent.md commands, companion .prompt.md files, and .vscode/settings.json - Verified byte-for-byte parity with old release script output - Copilot auto-registered in INTEGRATION_REGISTRY - 70 tests (22 new: base primitives + copilot integration) Part of #1924 * feat: Stage 2b — --integration flag, routing, agent.json, shared infra - Added --integration flag to init() (mutually exclusive with --ai) - --ai copilot auto-promotes to integration path with migration nudge - Integration setup writes .specify/agent.json with integration key - _install_shared_infra() copies scripts and templates to .specify/ - init-options.json records 'integration' key when used - 4 new CLI tests: mutual exclusivity, unknown rejection, copilot end-to-end, auto-promote (74 total integration tests) Part of #1924 * feat: Stage 2 completion — integration scripts, integration.json, shared manifest - Added copilot/scripts/update-context.sh and .ps1 (thin wrappers that delegate to the shared update-agent-context script) - CopilotIntegration.setup() installs integration scripts to .specify/integrations/copilot/scripts/ - Renamed agent.json → integration.json with script paths - _install_shared_infra() now tracks files in integration-shared.manifest.json - Updated tests: scripts installed, integration.json has script paths, shared manifest recorded (74 tests) Part of #1924 * refactor: rename shared manifest to speckit.manifest.json Cleaner naming — the shared infrastructure (scripts, templates) belongs to spec-kit itself, not to any specific integration. * fix: copilot update-context scripts reflect target architecture Scripts now source shared functions (via SPECKIT_SOURCE_ONLY=1) and call update_agent_file directly with .github/copilot-instructions.md, rather than delegating back to the shared case statement. * fix: simplify copilot scripts — dispatcher sources common functions Integration scripts now contain only copilot-specific logic (target path + agent name). The dispatcher is responsible for sourcing shared functions before calling the integration script. * fix: copilot update-context scripts are self-contained implementations These scripts ARE the implementation — the dispatcher calls them. They source common.sh + update-agent-context functions, gather feature/plan data, then call update_agent_file with the copilot target path (.github/copilot-instructions.md). * docs: add Stage 7 activation note to copilot update-context scripts * test: add complete file inventory test for copilot integration Validates every single file (37 total) produced by specify init --integration copilot --script sh --no-git. * test: add PowerShell file inventory test for copilot integration Validates all 37 files produced by --script ps variant, including .specify/scripts/powershell/ instead of bash. * refactor: split test_integrations.py into tests/integrations/ directory - test_base.py: IntegrationOption, IntegrationBase, MarkdownIntegration, primitives - test_manifest.py: IntegrationManifest, path traversal, persistence, validation - test_registry.py: INTEGRATION_REGISTRY - test_copilot.py: CopilotIntegration unit tests - test_cli.py: --integration flag, auto-promote, file inventories (sh + ps) - conftest.py: shared StubIntegration helper 76 integration tests + 48 consistency tests = 124 total, all passing. * refactor: move file inventory tests from test_cli to test_copilot File inventories are copilot-specific. test_cli.py now only tests CLI flag mechanics (mutual exclusivity, unknown rejection, auto-promote). * fix: skip JSONC merge to preserve user settings, fix docstring - _merge_vscode_settings() now returns early (skips merge) when existing settings.json can't be parsed (e.g. JSONC with comments), instead of overwriting with empty settings - Updated _install_shared_infra() docstring to match implementation (scripts + templates, speckit.manifest.json) * fix: warn user when JSONC settings merge is skipped * fix: show template content when JSONC merge is skipped User now sees the exact settings they should add manually. * fix: document process_template requirement, merge scripts without rmtree - base.py setup() docstring now explicitly states raw copy behavior and directs to CopilotIntegration for process_template example - _install_shared_infra() uses merge/overwrite instead of rmtree to preserve user-added files under .specify/scripts/ * fix: don't overwrite pre-existing shared scripts or templates Only write files that don't already exist — preserves any user modifications to shared scripts (common.sh etc.) and templates. * fix: warn user about skipped pre-existing shared files Lists all shared scripts and templates that were not copied because they already existed in the project. * test: add test for shared infra skip behavior on pre-existing files Verifies that _install_shared_infra() preserves user-modified scripts and templates while still installing missing ones. * fix: address review — containment check, deterministic prompts, manifest accuracy - CopilotIntegration.setup() adds dest containment check (relative_to) - Companion prompts generated from templates list, not directory glob - _install_shared_infra() only records files actually copied (not pre-existing) - VS Code settings tests made unconditional (assert template exists) - Inventory tests use .as_posix() for cross-platform paths * fix: correct PS1 function names, document SPECKIT_SOURCE_ONLY prerequisite - Fixed Get-FeaturePaths → Get-FeaturePathsEnv, Read-PlanData → Parse-PlanData - Documented that shared scripts must guard Main with SPECKIT_SOURCE_ONLY before these integration scripts can be activated (Stage 7) * fix: add dict type check for settings merge, simplify PS1 to subprocess - _merge_vscode_settings() skips merge with warning if parsed JSON is not a dict (array, null, etc.) - PS1 update-context.ps1 uses & invocation instead of dot-sourcing since the shared script runs Main unconditionally * fix: skip-write on no-op merge, bash subprocess, dynamic integration list - _merge_vscode_settings() only writes when keys were actually added - update-context.sh uses exec subprocess like PS1 version - Unknown integration error lists available integrations dynamically * fix: align path rewriting with release script, add .specify/.specify/ fix Path rewrite regex matches the release script's rewrite_paths() exactly (verified byte-identical output). Added .specify/.specify/ double-prefix fix for additional safety.
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""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).
|
|
"""
|
|
|
|
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
|
|
|
|
@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.<script_type>`` value from YAML frontmatter
|
|
2. Replace ``{SCRIPT}`` with the extracted script command
|
|
3. Extract ``agent_scripts.<script_type>`` 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 (matches release script's rewrite_paths())
|
|
content = re.sub(r"(/?)memory/", r".specify/memory/", content)
|
|
content = re.sub(r"(/?)scripts/", r".specify/scripts/", content)
|
|
content = re.sub(r"(/?)templates/", r".specify/templates/", content)
|
|
# Fix double-prefix (same as release script's .specify.specify/ fix)
|
|
content = content.replace(".specify.specify/", ".specify/")
|
|
content = content.replace(".specify/.specify/", ".specify/")
|
|
|
|
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.
|
|
|
|
The default ``setup()`` from ``IntegrationBase`` copies templates
|
|
into the agent's commands directory — which is correct for the
|
|
standard Markdown case.
|
|
"""
|
|
|
|
# MarkdownIntegration inherits IntegrationBase.setup() as-is.
|
|
# Future stages may add markdown-specific path rewriting here.
|
|
pass
|