Skip to content

Commit 6916148

Browse files
committed
feat: Stage 1 — integration foundation (base classes, manifest, registry)
Add the integrations package with: - IntegrationBase ABC and MarkdownIntegration base class - IntegrationOption dataclass for per-integration CLI options - IntegrationManifest with SHA-256 hash-tracked install/uninstall - INTEGRATION_REGISTRY (empty, populated in later stages) - 34 tests at 98% coverage Purely additive — no existing code modified. Part of #1924
1 parent b19a7ee commit 6916148

4 files changed

Lines changed: 729 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Integration registry for AI coding assistants.
2+
3+
Each integration is a self-contained subpackage that handles setup/teardown
4+
for a specific AI assistant (Copilot, Claude, Gemini, etc.).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
from .base import IntegrationBase
13+
14+
# Maps integration key → IntegrationBase instance.
15+
# Populated by later stages as integrations are migrated.
16+
INTEGRATION_REGISTRY: dict[str, IntegrationBase] = {}
17+
18+
19+
def _register(integration: IntegrationBase) -> None:
20+
"""Register an integration instance in the global registry."""
21+
INTEGRATION_REGISTRY[integration.key] = integration
22+
23+
24+
def get_integration(key: str) -> IntegrationBase | None:
25+
"""Return the integration for *key*, or ``None`` if not registered."""
26+
return INTEGRATION_REGISTRY.get(key)
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Base classes for AI-assistant integrations.
2+
3+
Provides:
4+
- ``IntegrationOption`` — declares a CLI option an integration accepts.
5+
- ``IntegrationBase`` — abstract base every integration must implement.
6+
- ``MarkdownIntegration`` — concrete base for standard Markdown-format
7+
integrations (the common case — subclass, set three class attrs, done).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import shutil
13+
from abc import ABC
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
from typing import Any
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# IntegrationOption
21+
# ---------------------------------------------------------------------------
22+
23+
@dataclass(frozen=True)
24+
class IntegrationOption:
25+
"""Declares an option that an integration accepts via ``--integration-options``.
26+
27+
Attributes:
28+
name: The flag name (e.g. ``"--commands-dir"``).
29+
is_flag: ``True`` for boolean flags (``--skills``).
30+
required: ``True`` if the option must be supplied.
31+
default: Default value when not supplied (``None`` → no default).
32+
help: One-line description shown in ``specify integrate info``.
33+
"""
34+
35+
name: str
36+
is_flag: bool = False
37+
required: bool = False
38+
default: Any = None
39+
help: str = ""
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# IntegrationBase — abstract base class
44+
# ---------------------------------------------------------------------------
45+
46+
class IntegrationBase(ABC):
47+
"""Abstract base class every integration must implement.
48+
49+
Subclasses must set the following class-level attributes:
50+
51+
* ``key`` — unique identifier, matches actual CLI tool name
52+
* ``config`` — dict compatible with ``AGENT_CONFIG`` entries
53+
* ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS``
54+
55+
And may optionally set:
56+
57+
* ``context_file`` — path (relative to project root) of the agent
58+
context/instructions file (e.g. ``"CLAUDE.md"``)
59+
"""
60+
61+
# -- Must be set by every subclass ------------------------------------
62+
63+
key: str = ""
64+
"""Unique integration key — should match the actual CLI tool name."""
65+
66+
config: dict[str, Any] = {}
67+
"""Metadata dict matching the ``AGENT_CONFIG`` shape."""
68+
69+
registrar_config: dict[str, Any] = {}
70+
"""Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape."""
71+
72+
# -- Optional ---------------------------------------------------------
73+
74+
context_file: str | None = None
75+
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
76+
77+
# -- Public API -------------------------------------------------------
78+
79+
@classmethod
80+
def options(cls) -> list[IntegrationOption]:
81+
"""Return options this integration accepts. Default: none."""
82+
return []
83+
84+
def templates_dir(self) -> Path:
85+
"""Return the path to this integration's bundled templates.
86+
87+
By convention, templates live in a ``templates/`` subdirectory next
88+
to the integration's ``__init__.py``.
89+
"""
90+
import inspect
91+
92+
module_file = inspect.getfile(type(self))
93+
return Path(module_file).resolve().parent / "templates"
94+
95+
def setup(
96+
self,
97+
project_root: Path,
98+
manifest: Any,
99+
parsed_options: dict[str, Any] | None = None,
100+
**opts: Any,
101+
) -> list[Path]:
102+
"""Install integration files into *project_root*.
103+
104+
Returns the list of files created. The default implementation
105+
copies every file from ``templates_dir()`` into the commands
106+
directory derived from ``config``, recording each in *manifest*.
107+
"""
108+
created: list[Path] = []
109+
tpl_dir = self.templates_dir()
110+
if not tpl_dir.is_dir():
111+
return created
112+
113+
folder = self.config.get("folder")
114+
if not folder:
115+
return created
116+
117+
subdir = self.config.get("commands_subdir", "commands")
118+
dest = project_root / folder / subdir
119+
dest.mkdir(parents=True, exist_ok=True)
120+
121+
for src_file in sorted(tpl_dir.iterdir()):
122+
if src_file.is_file():
123+
dst_file = dest / src_file.name
124+
shutil.copy2(src_file, dst_file)
125+
manifest.record_existing(dst_file.relative_to(project_root))
126+
created.append(dst_file)
127+
128+
return created
129+
130+
def teardown(
131+
self,
132+
project_root: Path,
133+
manifest: Any,
134+
*,
135+
force: bool = False,
136+
) -> tuple[list[Path], list[Path]]:
137+
"""Uninstall integration files from *project_root*.
138+
139+
Delegates to ``manifest.uninstall()`` which only removes files
140+
whose hash still matches the recorded value (unless *force*).
141+
142+
Returns ``(removed, skipped)`` file lists.
143+
"""
144+
return manifest.uninstall(project_root, force=force)
145+
146+
# -- Convenience helpers for subclasses -------------------------------
147+
148+
def install(
149+
self,
150+
project_root: Path,
151+
manifest: Any,
152+
parsed_options: dict[str, Any] | None = None,
153+
**opts: Any,
154+
) -> list[Path]:
155+
"""High-level install — calls ``setup()`` and returns created files."""
156+
return self.setup(
157+
project_root, manifest, parsed_options=parsed_options, **opts
158+
)
159+
160+
def uninstall(
161+
self,
162+
project_root: Path,
163+
manifest: Any,
164+
*,
165+
force: bool = False,
166+
) -> tuple[list[Path], list[Path]]:
167+
"""High-level uninstall — calls ``teardown()``."""
168+
return self.teardown(project_root, manifest, force=force)
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# MarkdownIntegration — covers ~20 standard agents
173+
# ---------------------------------------------------------------------------
174+
175+
class MarkdownIntegration(IntegrationBase):
176+
"""Concrete base for integrations that use standard Markdown commands.
177+
178+
Subclasses only need to set ``key``, ``config``, ``registrar_config``
179+
(and optionally ``context_file``). Everything else is inherited.
180+
181+
The default ``setup()`` from ``IntegrationBase`` copies templates
182+
into the agent's commands directory — which is correct for the
183+
standard Markdown case.
184+
"""
185+
186+
# MarkdownIntegration inherits IntegrationBase.setup() as-is.
187+
# Future stages may add markdown-specific path rewriting here.
188+
pass
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Hash-tracked installation manifest for integrations.
2+
3+
Each installed integration records the files it created together with
4+
their SHA-256 hashes. On uninstall only files whose hash still matches
5+
the recorded value are removed — modified files are left in place and
6+
reported to the caller.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import hashlib
12+
import json
13+
from datetime import datetime, timezone
14+
from pathlib import Path
15+
from typing import Any
16+
17+
18+
def _sha256(path: Path) -> str:
19+
"""Return the hex SHA-256 digest of *path*."""
20+
h = hashlib.sha256()
21+
with open(path, "rb") as fh:
22+
for chunk in iter(lambda: fh.read(8192), b""):
23+
h.update(chunk)
24+
return h.hexdigest()
25+
26+
27+
class IntegrationManifest:
28+
"""Tracks files installed by a single integration.
29+
30+
Parameters:
31+
key: Integration identifier (e.g. ``"copilot"``).
32+
project_root: Absolute path to the project directory.
33+
version: CLI version string recorded in the manifest.
34+
"""
35+
36+
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
37+
self.key = key
38+
self.project_root = project_root.resolve()
39+
self.version = version
40+
self._files: dict[str, str] = {} # rel_path → sha256 hex
41+
self._installed_at: str = ""
42+
43+
# -- Manifest file location -------------------------------------------
44+
45+
@property
46+
def manifest_path(self) -> Path:
47+
"""Path to the on-disk manifest JSON."""
48+
return self.project_root / ".specify" / "integrations" / f"{self.key}.manifest.json"
49+
50+
# -- Recording files --------------------------------------------------
51+
52+
def record_file(self, rel_path: str | Path, content: bytes | str) -> Path:
53+
"""Write *content* to *rel_path* (relative to project root) and record its hash.
54+
55+
Creates parent directories as needed. Returns the absolute path
56+
of the written file.
57+
"""
58+
rel = Path(rel_path)
59+
abs_path = self.project_root / rel
60+
abs_path.parent.mkdir(parents=True, exist_ok=True)
61+
62+
if isinstance(content, str):
63+
content = content.encode("utf-8")
64+
abs_path.write_bytes(content)
65+
66+
self._files[str(rel)] = hashlib.sha256(content).hexdigest()
67+
return abs_path
68+
69+
def record_existing(self, rel_path: str | Path) -> None:
70+
"""Record the hash of an already-existing file at *rel_path*."""
71+
rel = Path(rel_path)
72+
abs_path = self.project_root / rel
73+
self._files[str(rel)] = _sha256(abs_path)
74+
75+
# -- Querying ---------------------------------------------------------
76+
77+
@property
78+
def files(self) -> dict[str, str]:
79+
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
80+
return dict(self._files)
81+
82+
def check_modified(self) -> list[str]:
83+
"""Return relative paths of tracked files whose content changed on disk."""
84+
modified: list[str] = []
85+
for rel, expected_hash in self._files.items():
86+
abs_path = self.project_root / rel
87+
if not abs_path.exists():
88+
continue
89+
if _sha256(abs_path) != expected_hash:
90+
modified.append(rel)
91+
return modified
92+
93+
# -- Uninstall --------------------------------------------------------
94+
95+
def uninstall(
96+
self,
97+
project_root: Path | None = None,
98+
*,
99+
force: bool = False,
100+
) -> tuple[list[Path], list[Path]]:
101+
"""Remove tracked files whose hash still matches.
102+
103+
Parameters:
104+
project_root: Override for the project root.
105+
force: If ``True``, remove files even if modified.
106+
107+
Returns:
108+
``(removed, skipped)`` — absolute paths.
109+
"""
110+
root = (project_root or self.project_root).resolve()
111+
removed: list[Path] = []
112+
skipped: list[Path] = []
113+
114+
for rel, expected_hash in self._files.items():
115+
abs_path = root / rel
116+
if not abs_path.exists():
117+
continue
118+
if not force and _sha256(abs_path) != expected_hash:
119+
skipped.append(abs_path)
120+
continue
121+
abs_path.unlink()
122+
removed.append(abs_path)
123+
# Clean up empty parent directories up to project root
124+
parent = abs_path.parent
125+
while parent != root:
126+
try:
127+
parent.rmdir() # only succeeds if empty
128+
except OSError:
129+
break
130+
parent = parent.parent
131+
132+
# Remove the manifest file itself
133+
if self.manifest_path.exists():
134+
self.manifest_path.unlink()
135+
parent = self.manifest_path.parent
136+
while parent != root:
137+
try:
138+
parent.rmdir()
139+
except OSError:
140+
break
141+
parent = parent.parent
142+
143+
return removed, skipped
144+
145+
# -- Persistence ------------------------------------------------------
146+
147+
def save(self) -> Path:
148+
"""Write the manifest to disk. Returns the manifest path."""
149+
self._installed_at = self._installed_at or datetime.now(timezone.utc).isoformat()
150+
data: dict[str, Any] = {
151+
"integration": self.key,
152+
"version": self.version,
153+
"installed_at": self._installed_at,
154+
"files": self._files,
155+
}
156+
path = self.manifest_path
157+
path.parent.mkdir(parents=True, exist_ok=True)
158+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
159+
return path
160+
161+
@classmethod
162+
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
163+
"""Load an existing manifest from disk.
164+
165+
Raises ``FileNotFoundError`` if the manifest does not exist.
166+
"""
167+
inst = cls(key, project_root)
168+
path = inst.manifest_path
169+
data = json.loads(path.read_text(encoding="utf-8"))
170+
inst.version = data.get("version", "")
171+
inst._installed_at = data.get("installed_at", "")
172+
inst._files = data.get("files", {})
173+
return inst

0 commit comments

Comments
 (0)