Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture (#2038)

* Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture

Migrate all standard markdown integrations to self-contained subpackages
under integrations/. Each subclasses MarkdownIntegration with config-only
overrides (~10 lines per __init__.py).

Integrations migrated (19):
claude, qwen, opencode, junie, kilocode, auggie, roo, codebuddy,
qodercli, amp, shai, bob, trae, pi, iflow, kiro-cli, windsurf,
vibe, cursor-agent

Changes:
- Create integrations/<key>/ subpackage with __init__.py and scripts/
  (update-context.sh, update-context.ps1) for each integration
- Register all 19 in INTEGRATION_REGISTRY (20 total with copilot)
- MarkdownIntegration.setup() processes templates (replaces {SCRIPT},
  {ARGS}, __AGENT__; strips frontmatter blocks; rewrites paths)
- Extract install_scripts() to IntegrationBase; refactor copilot to use it
- Generalize --ai auto-promote from copilot-only to registry-driven:
  any integration registered in INTEGRATION_REGISTRY auto-promotes.
  Unregistered agents (gemini, tabnine, codex, kimi, agy, generic)
  continue through the legacy --ai path unchanged.
- Fix cursor/cursor-agent key mismatch in CommandRegistrar.AGENT_CONFIGS
- Add missing vibe entry to CommandRegistrar.AGENT_CONFIGS
- Update kiro alias test to reflect auto-promote behavior

Testing:
- Per-agent test files (test_integration_<agent>.py) with shared mixin
- 1316 tests passing, 0 failures
- Complete file inventory tests for both sh and ps variants
- Byte-for-byte validated against v0.4.3 release packages (684 files)

* Address PR review: fix repo root detection and no-op test

- Fix repo root fallback in all 20 update-context.sh scripts: walk up
  from script location to find .specify/ instead of falling back to pwd
- Fix repo root fallback in all 20 update-context.ps1 scripts: walk up
  from script location to find .specify/ instead of falling back to $PWD
- Add assertions to test_setup_writes_to_correct_directory: verify
  expected_dir exists and all command files reside under it

* Fix REPO_ROOT priority: prefer .specify walk-up over git root

In monorepos the git toplevel may differ from the project root that
contains .specify/. The previous fix still preferred git rev-parse
over the walk-up result.

Bash scripts (20): prefer the discovered _root when it contains
.specify/; only accept git root if it also contains .specify/.

PowerShell scripts (20): validate git root contains .specify/ before
using it; fall back to walking up from script directory otherwise.

* Guard git call with try/catch in PowerShell scripts

With $ErrorActionPreference = 'Stop', an unguarded git rev-parse
throws a terminating CommandNotFoundException when git is not
installed, preventing the .specify walk-up fallback from running.

Wrap the git call in try/catch across all 20 update-context.ps1
scripts so the fallback works reliably without git.

* Rename hyphenated package dirs to valid Python identifiers

Rename kiro-cli → kiro_cli and cursor-agent → cursor_agent so the
packages can be imported with normal Python syntax instead of
importlib.  The user-facing integration key (IntegrationBase.key)
stays hyphenated to match the actual CLI tool / binary name.

Also reorganize _register_builtins(): imports and registrations
are now grouped alphabetically with clear section comments.

* Reuse CommandRegistrar path rewriting in process_template()

Replace the duplicated regex-based path rewriting in
MarkdownIntegration.process_template() with a call to the shared
CommandRegistrar._rewrite_project_relative_paths() implementation.

This ensures extension-local paths are preserved and boundary rules
stay consistent across the codebase.

* Promote _rewrite_project_relative_paths to public API

Rename CommandRegistrar._rewrite_project_relative_paths() to
rewrite_project_relative_paths() (drop leading underscore) so
integrations can call it without reaching into a private method
across subsystem boundaries.

Addresses PR review feedback:
https://github.com/github/spec-kit/pull/2038#discussion_r3022105627

* Broaden TestRegistrarKeyAlignment to cover all integration keys

Parametrize across ALL_INTEGRATION_KEYS instead of only checking
cursor-agent and vibe.  Keeps a separate negative test for the
stale 'cursor' shorthand.

Addresses PR review feedback:
https://github.com/github/spec-kit/pull/2038#discussion_r3022269032
This commit is contained in:
Manfred Riem
2026-04-01 09:17:21 -05:00
committed by GitHub
parent 3113b72d6f
commit 255371d367
88 changed files with 2113 additions and 61 deletions

View File

@@ -206,6 +206,53 @@ class IntegrationBase(ABC):
manifest.record_existing(rel)
return dest
def integration_scripts_dir(self) -> Path | None:
"""Return path to this integration's bundled ``scripts/`` directory.
Looks for a ``scripts/`` sibling of the module that defines the
concrete subclass (not ``IntegrationBase`` itself).
Returns ``None`` if the directory doesn't exist.
"""
import inspect
cls_file = inspect.getfile(type(self))
scripts = Path(cls_file).resolve().parent / "scripts"
return scripts if scripts.is_dir() else None
def install_scripts(
self,
project_root: Path,
manifest: IntegrationManifest,
) -> list[Path]:
"""Copy integration-specific scripts into the project.
Copies files from this integration's ``scripts/`` directory to
``.specify/integrations/<key>/scripts/`` in the project. Shell
scripts are made executable. All copied files are recorded in
*manifest*.
Returns the list of files created.
"""
scripts_src = self.integration_scripts_dir()
if not scripts_src:
return []
created: list[Path] = []
scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts"
scripts_dest.mkdir(parents=True, exist_ok=True)
for src_script in sorted(scripts_src.iterdir()):
if not src_script.is_file():
continue
dst_script = scripts_dest / src_script.name
shutil.copy2(src_script, dst_script)
if dst_script.suffix == ".sh":
dst_script.chmod(dst_script.stat().st_mode | 0o111)
self.record_file_in_manifest(dst_script, project_root, manifest)
created.append(dst_script)
return created
@staticmethod
def process_template(
content: str,
@@ -299,13 +346,11 @@ class IntegrationBase(ABC):
# 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/")
# 7. Rewrite paths — delegate to the shared implementation in
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
content = CommandRegistrar.rewrite_project_relative_paths(content)
return content
@@ -405,11 +450,51 @@ class MarkdownIntegration(IntegrationBase):
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.
``setup()`` processes command templates (replacing ``{SCRIPT}``,
``{ARGS}``, ``__AGENT__``, rewriting paths) and installs
integration-specific scripts (``update-context.sh`` / ``.ps1``).
"""
# MarkdownIntegration inherits IntegrationBase.setup() as-is.
# Future stages may add markdown-specific path rewriting here.
pass
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
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
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created