diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 0d7a71242..ed131103c 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -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()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 0320d7f7a..a88039b9a 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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 diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py new file mode 100644 index 000000000..d66f0b80b --- /dev/null +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -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" diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 new file mode 100644 index 000000000..51c9e0bc8 --- /dev/null +++ b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 @@ -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 diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh new file mode 100644 index 000000000..c4e5003a5 --- /dev/null +++ b/src/specify_cli/integrations/gemini/scripts/update-context.sh @@ -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 diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py new file mode 100644 index 000000000..2928a214a --- /dev/null +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -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" diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 new file mode 100644 index 000000000..0ffb3a164 --- /dev/null +++ b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 @@ -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 diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh new file mode 100644 index 000000000..fe5050b6e --- /dev/null +++ b/src/specify_cli/integrations/tabnine/scripts/update-context.sh @@ -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 diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py new file mode 100644 index 000000000..e7b506782 --- /dev/null +++ b/tests/integrations/test_integration_base_toml.py @@ -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 --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 --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))}" + ) diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py new file mode 100644 index 000000000..9be5985e2 --- /dev/null +++ b/tests/integrations/test_integration_gemini.py @@ -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" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py new file mode 100644 index 000000000..95eb47cc1 --- /dev/null +++ b/tests/integrations/test_integration_tabnine.py @@ -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"