Stage 4: TOML integrations — gemini and tabnine migrated to plugin architecture (#2050)

Add TomlIntegration base class in base.py that mirrors MarkdownIntegration:
- Overrides command_filename() for .toml extension
- Extracts description from YAML frontmatter for top-level TOML key
- Renders prompt body in TOML multiline basic strings with escaped backslashes
- Keeps full processed template (including frontmatter) as prompt body
- Byte-for-byte parity with v0.4.4 release ZIP output

Create integrations/gemini/ and integrations/tabnine/ subpackages:
- Config-only __init__.py subclassing TomlIntegration
- Integration-specific update-context scripts (sh + ps1)

Add TomlIntegrationTests mixin with TOML-specific validations:
- Valid TOML parsing, description/prompt keys, {{args}} placeholder
- Setup/teardown, manifest tracking, install/uninstall round-trips
- CLI auto-promote (--ai) and --integration flag tests
- Complete file inventory tests (sh + ps)

Register both in INTEGRATION_REGISTRY; --ai auto-promote works automatically.
This commit is contained in:
Manfred Riem
2026-04-01 10:26:48 -05:00
committed by GitHub
parent b606b38512
commit 682ffbfc0d
11 changed files with 651 additions and 0 deletions

View File

@@ -53,6 +53,7 @@ def _register_builtins() -> None:
from .codebuddy import CodebuddyIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .gemini import GeminiIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
@@ -63,6 +64,7 @@ def _register_builtins() -> None:
from .qwen import QwenIntegration
from .roo import RooIntegration
from .shai import ShaiIntegration
from .tabnine import TabnineIntegration
from .trae import TraeIntegration
from .vibe import VibeIntegration
from .windsurf import WindsurfIntegration
@@ -75,6 +77,7 @@ def _register_builtins() -> None:
_register(CodebuddyIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(GeminiIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())
@@ -85,6 +88,7 @@ def _register_builtins() -> None:
_register(QwenIntegration())
_register(RooIntegration())
_register(ShaiIntegration())
_register(TabnineIntegration())
_register(TraeIntegration())
_register(VibeIntegration())
_register(WindsurfIntegration())

View File

@@ -5,6 +5,8 @@ Provides:
- ``IntegrationBase`` — abstract base every integration must implement.
- ``MarkdownIntegration`` — concrete base for standard Markdown-format
integrations (the common case — subclass, set three class attrs, done).
- ``TomlIntegration`` — concrete base for TOML-format integrations
(Gemini, Tabnine — subclass, set three class attrs, done).
"""
from __future__ import annotations
@@ -498,3 +500,136 @@ class MarkdownIntegration(IntegrationBase):
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
# ---------------------------------------------------------------------------
class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
TOML format (``description`` key + ``prompt`` multiline string).
"""
def command_filename(self, template_name: str) -> str:
"""TOML commands use ``.toml`` extension."""
return f"speckit.{template_name}.toml"
@staticmethod
def _extract_description(content: str) -> str:
"""Extract the ``description`` value from YAML frontmatter.
Scans lines between the first pair of ``---`` delimiters for a
top-level ``description:`` key. Returns the value (with
surrounding quotes stripped) or an empty string if not found.
"""
in_frontmatter = False
for line in content.splitlines():
stripped = line.rstrip("\n\r")
if stripped == "---":
if not in_frontmatter:
in_frontmatter = True
continue
break # second ---
if in_frontmatter and stripped.startswith("description:"):
_, _, value = stripped.partition(":")
return value.strip().strip('"').strip("'")
return ""
@staticmethod
def _render_toml(description: str, body: str) -> str:
"""Render a TOML command file from description and body.
Uses multiline basic strings (``\"\"\"``) with backslashes
escaped, matching the output of the release script. Falls back
to multiline literal strings (``'''``) if the body contains
``\"\"\"``, then to an escaped basic string as a last resort.
The body is rstrip'd so the closing delimiter appears on the line
immediately after the last content line — matching the release
script's ``echo "$body"; echo '\"\"\"'`` pattern.
"""
toml_lines: list[str] = []
if description:
desc = description.replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
body = body.rstrip("\n")
# Escape backslashes for basic multiline strings.
escaped = body.replace("\\", "\\\\")
if '"""' not in escaped:
toml_lines.append('prompt = """')
toml_lines.append(escaped)
toml_lines.append('"""')
elif "'''" not in body:
toml_lines.append("prompt = '''")
toml_lines.append(body)
toml_lines.append("'''")
else:
escaped_body = (
body.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
toml_lines.append(f'prompt = "{escaped_body}"')
return "\n".join(toml_lines) + "\n"
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", "{{args}}") if self.registrar_config else "{{args}}"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
toml_content = self._render_toml(description, processed)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
toml_content, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -0,0 +1,21 @@
"""Gemini CLI integration."""
from ..base import TomlIntegration
class GeminiIntegration(TomlIntegration):
key = "gemini"
config = {
"name": "Gemini CLI",
"folder": ".gemini/",
"commands_subdir": "commands",
"install_url": "https://github.com/google-gemini/gemini-cli",
"requires_cli": True,
}
registrar_config = {
"dir": ".gemini/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"

View File

@@ -0,0 +1,23 @@
# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# update-context.sh — Gemini CLI integration: create/update GEMINI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini

View File

@@ -0,0 +1,21 @@
"""Tabnine CLI integration."""
from ..base import TomlIntegration
class TabnineIntegration(TomlIntegration):
key = "tabnine"
config = {
"name": "Tabnine CLI",
"folder": ".tabnine/agent/",
"commands_subdir": "commands",
"install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli",
"requires_cli": True,
}
registrar_config = {
"dir": ".tabnine/agent/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml",
}
context_file = "TABNINE.md"

View File

@@ -0,0 +1,23 @@
# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# update-context.sh — Tabnine CLI integration: create/update TABNINE.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine

View File

@@ -0,0 +1,346 @@
"""Reusable test mixin for standard TomlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``TomlIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` closely — same test structure,
adapted for TOML output format.
"""
import os
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import TomlIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TomlIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".gemini/"
COMMANDS_SUBDIR: str — e.g. "commands"
REGISTRAR_DIR: str — e.g. ".gemini/commands"
CONTEXT_FILE: str — e.g. "GEMINI.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_toml_integration(self):
assert isinstance(get_integration(self.KEY), TomlIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "toml"
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".toml"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".toml")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced and be valid TOML."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_toml_has_description(self, tmp_path):
"""Every TOML command file should have a description key."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert 'description = "' in content, f"{f.name} missing description key"
def test_toml_has_prompt(self, tmp_path):
"""Every TOML command file should have a prompt key."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "prompt = " in content, f"{f.name} missing prompt key"
def test_toml_uses_correct_arg_placeholder(self, tmp_path):
"""TOML commands must use {{args}} (from {ARGS} replacement)."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
# At least one file should contain {{args}} from the {ARGS} placeholder
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No TOML command file contains {{args}} placeholder"
def test_toml_is_valid(self, tmp_path):
"""Every generated TOML file must parse without errors."""
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
raw = f.read_bytes()
try:
parsed = tomllib.loads(raw.decode("utf-8"))
except Exception as exc:
raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
assert f"--integration {self.KEY}" in result.output
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*.toml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files (.toml)
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.toml")
# Integration scripts
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Framework files
files.append(f".specify/integration.json")
files.append(f".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(f".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh", "update-agent-context.sh"]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1", "update-agent-context.ps1"]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["agent-file-template.md", "checklist-template.md",
"constitution-template.md", "plan-template.md",
"spec-template.md", "tasks-template.md"]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file())
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file())
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -0,0 +1,11 @@
"""Tests for GeminiIntegration."""
from .test_integration_base_toml import TomlIntegrationTests
class TestGeminiIntegration(TomlIntegrationTests):
KEY = "gemini"
FOLDER = ".gemini/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".gemini/commands"
CONTEXT_FILE = "GEMINI.md"

View File

@@ -0,0 +1,11 @@
"""Tests for TabnineIntegration."""
from .test_integration_base_toml import TomlIntegrationTests
class TestTabnineIntegration(TomlIntegrationTests):
KEY = "tabnine"
FOLDER = ".tabnine/agent/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".tabnine/agent/commands"
CONTEXT_FILE = "TABNINE.md"