mirror of
https://github.com/github/spec-kit.git
synced 2026-04-02 18:53:09 +00:00
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:
@@ -53,6 +53,7 @@ def _register_builtins() -> None:
|
|||||||
from .codebuddy import CodebuddyIntegration
|
from .codebuddy import CodebuddyIntegration
|
||||||
from .copilot import CopilotIntegration
|
from .copilot import CopilotIntegration
|
||||||
from .cursor_agent import CursorAgentIntegration
|
from .cursor_agent import CursorAgentIntegration
|
||||||
|
from .gemini import GeminiIntegration
|
||||||
from .iflow import IflowIntegration
|
from .iflow import IflowIntegration
|
||||||
from .junie import JunieIntegration
|
from .junie import JunieIntegration
|
||||||
from .kilocode import KilocodeIntegration
|
from .kilocode import KilocodeIntegration
|
||||||
@@ -63,6 +64,7 @@ def _register_builtins() -> None:
|
|||||||
from .qwen import QwenIntegration
|
from .qwen import QwenIntegration
|
||||||
from .roo import RooIntegration
|
from .roo import RooIntegration
|
||||||
from .shai import ShaiIntegration
|
from .shai import ShaiIntegration
|
||||||
|
from .tabnine import TabnineIntegration
|
||||||
from .trae import TraeIntegration
|
from .trae import TraeIntegration
|
||||||
from .vibe import VibeIntegration
|
from .vibe import VibeIntegration
|
||||||
from .windsurf import WindsurfIntegration
|
from .windsurf import WindsurfIntegration
|
||||||
@@ -75,6 +77,7 @@ def _register_builtins() -> None:
|
|||||||
_register(CodebuddyIntegration())
|
_register(CodebuddyIntegration())
|
||||||
_register(CopilotIntegration())
|
_register(CopilotIntegration())
|
||||||
_register(CursorAgentIntegration())
|
_register(CursorAgentIntegration())
|
||||||
|
_register(GeminiIntegration())
|
||||||
_register(IflowIntegration())
|
_register(IflowIntegration())
|
||||||
_register(JunieIntegration())
|
_register(JunieIntegration())
|
||||||
_register(KilocodeIntegration())
|
_register(KilocodeIntegration())
|
||||||
@@ -85,6 +88,7 @@ def _register_builtins() -> None:
|
|||||||
_register(QwenIntegration())
|
_register(QwenIntegration())
|
||||||
_register(RooIntegration())
|
_register(RooIntegration())
|
||||||
_register(ShaiIntegration())
|
_register(ShaiIntegration())
|
||||||
|
_register(TabnineIntegration())
|
||||||
_register(TraeIntegration())
|
_register(TraeIntegration())
|
||||||
_register(VibeIntegration())
|
_register(VibeIntegration())
|
||||||
_register(WindsurfIntegration())
|
_register(WindsurfIntegration())
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Provides:
|
|||||||
- ``IntegrationBase`` — abstract base every integration must implement.
|
- ``IntegrationBase`` — abstract base every integration must implement.
|
||||||
- ``MarkdownIntegration`` — concrete base for standard Markdown-format
|
- ``MarkdownIntegration`` — concrete base for standard Markdown-format
|
||||||
integrations (the common case — subclass, set three class attrs, done).
|
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
|
from __future__ import annotations
|
||||||
@@ -498,3 +500,136 @@ class MarkdownIntegration(IntegrationBase):
|
|||||||
|
|
||||||
created.extend(self.install_scripts(project_root, manifest))
|
created.extend(self.install_scripts(project_root, manifest))
|
||||||
return created
|
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
|
||||||
|
|||||||
21
src/specify_cli/integrations/gemini/__init__.py
Normal file
21
src/specify_cli/integrations/gemini/__init__.py
Normal 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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
21
src/specify_cli/integrations/tabnine/__init__.py
Normal file
21
src/specify_cli/integrations/tabnine/__init__.py
Normal 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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
346
tests/integrations/test_integration_base_toml.py
Normal file
346
tests/integrations/test_integration_base_toml.py
Normal 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))}"
|
||||||
|
)
|
||||||
11
tests/integrations/test_integration_gemini.py
Normal file
11
tests/integrations/test_integration_gemini.py
Normal 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"
|
||||||
11
tests/integrations/test_integration_tabnine.py
Normal file
11
tests/integrations/test_integration_tabnine.py
Normal 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"
|
||||||
Reference in New Issue
Block a user