Skip to content

Commit 9b9ffd6

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 9b9ffd6

4 files changed

Lines changed: 793 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

0 commit comments

Comments
 (0)