Skip to content

Commit 804cd10

Browse files
Copilotmnriem
andauthored
Stage 1: Integration foundation — base classes, manifest system, and registry (#1925)
* 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 * fix: normalize manifest keys to POSIX, type manifest parameter - Store manifest file keys using as_posix() after resolving relative to project root, ensuring cross-platform portable manifests - Type the manifest parameter as IntegrationManifest (via TYPE_CHECKING import) instead of Any in IntegrationBase methods * fix: symlink safety in uninstall/setup, handle invalid JSON in load - uninstall() now uses non-resolved path for deletion so symlinks themselves are removed, not their targets; resolve only for containment validation - setup() keeps unresolved dst_file for copy; resolves separately for project-root validation - load() catches json.JSONDecodeError and re-raises as ValueError with the manifest path for clearer diagnostics - Added test for invalid JSON manifest loading * fix: lexical symlink containment, assert project_root consistency - uninstall() now uses os.path.normpath for lexical containment check instead of resolve(), so in-project symlinks pointing outside are still properly removed - setup() asserts manifest.project_root matches the passed project_root to prevent path mismatches between file operations and manifest recording * fix: handle non-files in check_modified/uninstall, validate manifest key - check_modified() treats non-regular-files (dirs, symlinks) as modified instead of crashing with IsADirectoryError - uninstall() skips directories (adds to skipped list), only unlinks files and symlinks - load() validates stored integration key matches the requested key * fix: safe symlink handling in uninstall - Broken symlinks now removable (lexists check via is_symlink fallback) - Symlinks never hashed (avoids following to external targets) - Symlinks only removed with force=True, otherwise skipped * fix: robust unlink, fail-fast config validation, symlink tests - uninstall() wraps path.unlink() in try/except OSError to avoid partial cleanup on race conditions or permission errors - setup() raises ValueError on missing config or folder instead of silently returning empty - Added 3 tests: symlink in check_modified, symlink skip/force in uninstall (47 total) * fix: check_modified uses lexical containment, explicit is_symlink check - check_modified() no longer calls _validate_rel_path (which resolves symlinks); uses lexical checks (is_absolute, '..' in parts) instead - is_symlink() checked before is_file() so symlinks to files are still treated as modified - Fixed templates_dir() docstring to match actual behavior --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
1 parent 4dff63a commit 804cd10

File tree

4 files changed

+974
-0
lines changed

4 files changed

+974
-0
lines changed
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: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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 TYPE_CHECKING, Any
17+
18+
if TYPE_CHECKING:
19+
from .manifest import IntegrationManifest
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# IntegrationOption
24+
# ---------------------------------------------------------------------------
25+
26+
@dataclass(frozen=True)
27+
class IntegrationOption:
28+
"""Declares an option that an integration accepts via ``--integration-options``.
29+
30+
Attributes:
31+
name: The flag name (e.g. ``"--commands-dir"``).
32+
is_flag: ``True`` for boolean flags (``--skills``).
33+
required: ``True`` if the option must be supplied.
34+
default: Default value when not supplied (``None`` → no default).
35+
help: One-line description shown in ``specify integrate info``.
36+
"""
37+
38+
name: str
39+
is_flag: bool = False
40+
required: bool = False
41+
default: Any = None
42+
help: str = ""
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# IntegrationBase — abstract base class
47+
# ---------------------------------------------------------------------------
48+
49+
class IntegrationBase(ABC):
50+
"""Abstract base class every integration must implement.
51+
52+
Subclasses must set the following class-level attributes:
53+
54+
* ``key`` — unique identifier, matches actual CLI tool name
55+
* ``config`` — dict compatible with ``AGENT_CONFIG`` entries
56+
* ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS``
57+
58+
And may optionally set:
59+
60+
* ``context_file`` — path (relative to project root) of the agent
61+
context/instructions file (e.g. ``"CLAUDE.md"``)
62+
"""
63+
64+
# -- Must be set by every subclass ------------------------------------
65+
66+
key: str = ""
67+
"""Unique integration key — should match the actual CLI tool name."""
68+
69+
config: dict[str, Any] | None = None
70+
"""Metadata dict matching the ``AGENT_CONFIG`` shape."""
71+
72+
registrar_config: dict[str, Any] | None = None
73+
"""Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape."""
74+
75+
# -- Optional ---------------------------------------------------------
76+
77+
context_file: str | None = None
78+
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
79+
80+
# -- Public API -------------------------------------------------------
81+
82+
@classmethod
83+
def options(cls) -> list[IntegrationOption]:
84+
"""Return options this integration accepts. Default: none."""
85+
return []
86+
87+
def templates_dir(self) -> Path:
88+
"""Return the path to this integration's bundled templates.
89+
90+
By convention, templates live in a ``templates/`` subdirectory
91+
next to the file where the integration class is defined.
92+
"""
93+
import inspect
94+
95+
module_file = inspect.getfile(type(self))
96+
return Path(module_file).resolve().parent / "templates"
97+
98+
def setup(
99+
self,
100+
project_root: Path,
101+
manifest: IntegrationManifest,
102+
parsed_options: dict[str, Any] | None = None,
103+
**opts: Any,
104+
) -> list[Path]:
105+
"""Install integration files into *project_root*.
106+
107+
Returns the list of files created. The default implementation
108+
copies every file from ``templates_dir()`` into the commands
109+
directory derived from ``config``, recording each in *manifest*.
110+
"""
111+
created: list[Path] = []
112+
tpl_dir = self.templates_dir()
113+
if not tpl_dir.is_dir():
114+
return created
115+
116+
if not self.config:
117+
raise ValueError(
118+
f"{type(self).__name__}.config is not set; integration "
119+
"subclasses must define a non-empty 'config' mapping."
120+
)
121+
folder = self.config.get("folder")
122+
if not folder:
123+
raise ValueError(
124+
f"{type(self).__name__}.config is missing required 'folder' entry."
125+
)
126+
127+
project_root_resolved = project_root.resolve()
128+
if manifest.project_root != project_root_resolved:
129+
raise ValueError(
130+
f"manifest.project_root ({manifest.project_root}) does not match "
131+
f"project_root ({project_root_resolved})"
132+
)
133+
subdir = self.config.get("commands_subdir", "commands")
134+
dest = (project_root / folder / subdir).resolve()
135+
# Ensure destination stays within the project root
136+
try:
137+
dest.relative_to(project_root_resolved)
138+
except ValueError as exc:
139+
raise ValueError(
140+
f"Integration destination {dest} escapes "
141+
f"project root {project_root_resolved}"
142+
) from exc
143+
144+
dest.mkdir(parents=True, exist_ok=True)
145+
146+
for src_file in sorted(tpl_dir.iterdir()):
147+
if src_file.is_file():
148+
dst_file = dest / src_file.name
149+
dst_resolved = dst_file.resolve()
150+
rel = dst_resolved.relative_to(project_root_resolved)
151+
shutil.copy2(src_file, dst_file)
152+
manifest.record_existing(rel)
153+
created.append(dst_file)
154+
155+
return created
156+
157+
def teardown(
158+
self,
159+
project_root: Path,
160+
manifest: IntegrationManifest,
161+
*,
162+
force: bool = False,
163+
) -> tuple[list[Path], list[Path]]:
164+
"""Uninstall integration files from *project_root*.
165+
166+
Delegates to ``manifest.uninstall()`` which only removes files
167+
whose hash still matches the recorded value (unless *force*).
168+
169+
Returns ``(removed, skipped)`` file lists.
170+
"""
171+
return manifest.uninstall(project_root, force=force)
172+
173+
# -- Convenience helpers for subclasses -------------------------------
174+
175+
def install(
176+
self,
177+
project_root: Path,
178+
manifest: IntegrationManifest,
179+
parsed_options: dict[str, Any] | None = None,
180+
**opts: Any,
181+
) -> list[Path]:
182+
"""High-level install — calls ``setup()`` and returns created files."""
183+
return self.setup(
184+
project_root, manifest, parsed_options=parsed_options, **opts
185+
)
186+
187+
def uninstall(
188+
self,
189+
project_root: Path,
190+
manifest: IntegrationManifest,
191+
*,
192+
force: bool = False,
193+
) -> tuple[list[Path], list[Path]]:
194+
"""High-level uninstall — calls ``teardown()``."""
195+
return self.teardown(project_root, manifest, force=force)
196+
197+
198+
# ---------------------------------------------------------------------------
199+
# MarkdownIntegration — covers ~20 standard agents
200+
# ---------------------------------------------------------------------------
201+
202+
class MarkdownIntegration(IntegrationBase):
203+
"""Concrete base for integrations that use standard Markdown commands.
204+
205+
Subclasses only need to set ``key``, ``config``, ``registrar_config``
206+
(and optionally ``context_file``). Everything else is inherited.
207+
208+
The default ``setup()`` from ``IntegrationBase`` copies templates
209+
into the agent's commands directory — which is correct for the
210+
standard Markdown case.
211+
"""
212+
213+
# MarkdownIntegration inherits IntegrationBase.setup() as-is.
214+
# Future stages may add markdown-specific path rewriting here.
215+
pass

0 commit comments

Comments
 (0)