Skip to content

Commit 0695542

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 0695542

4 files changed

Lines changed: 873 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
22+
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
23+
"""
24+
key = integration.key
25+
if not key:
26+
raise ValueError("Cannot register integration with an empty key.")
27+
if key in INTEGRATION_REGISTRY:
28+
raise KeyError(f"Integration with key {key!r} is already registered.")
29+
INTEGRATION_REGISTRY[key] = integration
30+
31+
32+
def get_integration(key: str) -> IntegrationBase | None:
33+
"""Return the integration for *key*, or ``None`` if not registered."""
34+
return INTEGRATION_REGISTRY.get(key)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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] | None = None
67+
"""Metadata dict matching the ``AGENT_CONFIG`` shape."""
68+
69+
registrar_config: dict[str, Any] | None = None
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+
if not self.config:
114+
return created
115+
folder = self.config.get("folder")
116+
if not folder:
117+
return created
118+
119+
project_root_resolved = project_root.resolve()
120+
subdir = self.config.get("commands_subdir", "commands")
121+
dest = (project_root / folder / subdir).resolve()
122+
# Ensure destination stays within the project root
123+
try:
124+
dest.relative_to(project_root_resolved)
125+
except ValueError as exc:
126+
raise ValueError(
127+
f"Integration destination {dest} escapes "
128+
f"project root {project_root_resolved}"
129+
) from exc
130+
131+
dest.mkdir(parents=True, exist_ok=True)
132+
133+
for src_file in sorted(tpl_dir.iterdir()):
134+
if src_file.is_file():
135+
dst_file = (dest / src_file.name).resolve()
136+
rel = dst_file.relative_to(project_root_resolved)
137+
shutil.copy2(src_file, dst_file)
138+
manifest.record_existing(rel)
139+
created.append(dst_file)
140+
141+
return created
142+
143+
def teardown(
144+
self,
145+
project_root: Path,
146+
manifest: Any,
147+
*,
148+
force: bool = False,
149+
) -> tuple[list[Path], list[Path]]:
150+
"""Uninstall integration files from *project_root*.
151+
152+
Delegates to ``manifest.uninstall()`` which only removes files
153+
whose hash still matches the recorded value (unless *force*).
154+
155+
Returns ``(removed, skipped)`` file lists.
156+
"""
157+
return manifest.uninstall(project_root, force=force)
158+
159+
# -- Convenience helpers for subclasses -------------------------------
160+
161+
def install(
162+
self,
163+
project_root: Path,
164+
manifest: Any,
165+
parsed_options: dict[str, Any] | None = None,
166+
**opts: Any,
167+
) -> list[Path]:
168+
"""High-level install — calls ``setup()`` and returns created files."""
169+
return self.setup(
170+
project_root, manifest, parsed_options=parsed_options, **opts
171+
)
172+
173+
def uninstall(
174+
self,
175+
project_root: Path,
176+
manifest: Any,
177+
*,
178+
force: bool = False,
179+
) -> tuple[list[Path], list[Path]]:
180+
"""High-level uninstall — calls ``teardown()``."""
181+
return self.teardown(project_root, manifest, force=force)
182+
183+
184+
# ---------------------------------------------------------------------------
185+
# MarkdownIntegration — covers ~20 standard agents
186+
# ---------------------------------------------------------------------------
187+
188+
class MarkdownIntegration(IntegrationBase):
189+
"""Concrete base for integrations that use standard Markdown commands.
190+
191+
Subclasses only need to set ``key``, ``config``, ``registrar_config``
192+
(and optionally ``context_file``). Everything else is inherited.
193+
194+
The default ``setup()`` from ``IntegrationBase`` copies templates
195+
into the agent's commands directory — which is correct for the
196+
standard Markdown case.
197+
"""
198+
199+
# MarkdownIntegration inherits IntegrationBase.setup() as-is.
200+
# Future stages may add markdown-specific path rewriting here.
201+
pass

0 commit comments

Comments
 (0)