Skip to content

Commit 682ffbf

Browse files
authored
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.
1 parent b606b38 commit 682ffbf

File tree

11 files changed

+651
-0
lines changed

11 files changed

+651
-0
lines changed

src/specify_cli/integrations/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _register_builtins() -> None:
5353
from .codebuddy import CodebuddyIntegration
5454
from .copilot import CopilotIntegration
5555
from .cursor_agent import CursorAgentIntegration
56+
from .gemini import GeminiIntegration
5657
from .iflow import IflowIntegration
5758
from .junie import JunieIntegration
5859
from .kilocode import KilocodeIntegration
@@ -63,6 +64,7 @@ def _register_builtins() -> None:
6364
from .qwen import QwenIntegration
6465
from .roo import RooIntegration
6566
from .shai import ShaiIntegration
67+
from .tabnine import TabnineIntegration
6668
from .trae import TraeIntegration
6769
from .vibe import VibeIntegration
6870
from .windsurf import WindsurfIntegration
@@ -75,6 +77,7 @@ def _register_builtins() -> None:
7577
_register(CodebuddyIntegration())
7678
_register(CopilotIntegration())
7779
_register(CursorAgentIntegration())
80+
_register(GeminiIntegration())
7881
_register(IflowIntegration())
7982
_register(JunieIntegration())
8083
_register(KilocodeIntegration())
@@ -85,6 +88,7 @@ def _register_builtins() -> None:
8588
_register(QwenIntegration())
8689
_register(RooIntegration())
8790
_register(ShaiIntegration())
91+
_register(TabnineIntegration())
8892
_register(TraeIntegration())
8993
_register(VibeIntegration())
9094
_register(WindsurfIntegration())

src/specify_cli/integrations/base.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- ``IntegrationBase`` — abstract base every integration must implement.
66
- ``MarkdownIntegration`` — concrete base for standard Markdown-format
77
integrations (the common case — subclass, set three class attrs, done).
8+
- ``TomlIntegration`` — concrete base for TOML-format integrations
9+
(Gemini, Tabnine — subclass, set three class attrs, done).
810
"""
911

1012
from __future__ import annotations
@@ -498,3 +500,136 @@ def setup(
498500

499501
created.extend(self.install_scripts(project_root, manifest))
500502
return created
503+
504+
505+
# ---------------------------------------------------------------------------
506+
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
507+
# ---------------------------------------------------------------------------
508+
509+
class TomlIntegration(IntegrationBase):
510+
"""Concrete base for integrations that use TOML command format.
511+
512+
Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
513+
``key``, ``config``, ``registrar_config`` (and optionally
514+
``context_file``). Everything else is inherited.
515+
516+
``setup()`` processes command templates through the same placeholder
517+
pipeline as ``MarkdownIntegration``, then converts the result to
518+
TOML format (``description`` key + ``prompt`` multiline string).
519+
"""
520+
521+
def command_filename(self, template_name: str) -> str:
522+
"""TOML commands use ``.toml`` extension."""
523+
return f"speckit.{template_name}.toml"
524+
525+
@staticmethod
526+
def _extract_description(content: str) -> str:
527+
"""Extract the ``description`` value from YAML frontmatter.
528+
529+
Scans lines between the first pair of ``---`` delimiters for a
530+
top-level ``description:`` key. Returns the value (with
531+
surrounding quotes stripped) or an empty string if not found.
532+
"""
533+
in_frontmatter = False
534+
for line in content.splitlines():
535+
stripped = line.rstrip("\n\r")
536+
if stripped == "---":
537+
if not in_frontmatter:
538+
in_frontmatter = True
539+
continue
540+
break # second ---
541+
if in_frontmatter and stripped.startswith("description:"):
542+
_, _, value = stripped.partition(":")
543+
return value.strip().strip('"').strip("'")
544+
return ""
545+
546+
@staticmethod
547+
def _render_toml(description: str, body: str) -> str:
548+
"""Render a TOML command file from description and body.
549+
550+
Uses multiline basic strings (``\"\"\"``) with backslashes
551+
escaped, matching the output of the release script. Falls back
552+
to multiline literal strings (``'''``) if the body contains
553+
``\"\"\"``, then to an escaped basic string as a last resort.
554+
555+
The body is rstrip'd so the closing delimiter appears on the line
556+
immediately after the last content line — matching the release
557+
script's ``echo "$body"; echo '\"\"\"'`` pattern.
558+
"""
559+
toml_lines: list[str] = []
560+
561+
if description:
562+
desc = description.replace('"', '\\"')
563+
toml_lines.append(f'description = "{desc}"')
564+
toml_lines.append("")
565+
566+
body = body.rstrip("\n")
567+
568+
# Escape backslashes for basic multiline strings.
569+
escaped = body.replace("\\", "\\\\")
570+
571+
if '"""' not in escaped:
572+
toml_lines.append('prompt = """')
573+
toml_lines.append(escaped)
574+
toml_lines.append('"""')
575+
elif "'''" not in body:
576+
toml_lines.append("prompt = '''")
577+
toml_lines.append(body)
578+
toml_lines.append("'''")
579+
else:
580+
escaped_body = (
581+
body.replace("\\", "\\\\")
582+
.replace('"', '\\"')
583+
.replace("\n", "\\n")
584+
.replace("\r", "\\r")
585+
.replace("\t", "\\t")
586+
)
587+
toml_lines.append(f'prompt = "{escaped_body}"')
588+
589+
return "\n".join(toml_lines) + "\n"
590+
591+
def setup(
592+
self,
593+
project_root: Path,
594+
manifest: IntegrationManifest,
595+
parsed_options: dict[str, Any] | None = None,
596+
**opts: Any,
597+
) -> list[Path]:
598+
templates = self.list_command_templates()
599+
if not templates:
600+
return []
601+
602+
project_root_resolved = project_root.resolve()
603+
if manifest.project_root != project_root_resolved:
604+
raise ValueError(
605+
f"manifest.project_root ({manifest.project_root}) does not match "
606+
f"project_root ({project_root_resolved})"
607+
)
608+
609+
dest = self.commands_dest(project_root).resolve()
610+
try:
611+
dest.relative_to(project_root_resolved)
612+
except ValueError as exc:
613+
raise ValueError(
614+
f"Integration destination {dest} escapes "
615+
f"project root {project_root_resolved}"
616+
) from exc
617+
dest.mkdir(parents=True, exist_ok=True)
618+
619+
script_type = opts.get("script_type", "sh")
620+
arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}"
621+
created: list[Path] = []
622+
623+
for src_file in templates:
624+
raw = src_file.read_text(encoding="utf-8")
625+
description = self._extract_description(raw)
626+
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
627+
toml_content = self._render_toml(description, processed)
628+
dst_name = self.command_filename(src_file.stem)
629+
dst_file = self.write_file_and_record(
630+
toml_content, dest / dst_name, project_root, manifest
631+
)
632+
created.append(dst_file)
633+
634+
created.extend(self.install_scripts(project_root, manifest))
635+
return created
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Gemini CLI integration."""
2+
3+
from ..base import TomlIntegration
4+
5+
6+
class GeminiIntegration(TomlIntegration):
7+
key = "gemini"
8+
config = {
9+
"name": "Gemini CLI",
10+
"folder": ".gemini/",
11+
"commands_subdir": "commands",
12+
"install_url": "https://github.com/google-gemini/gemini-cli",
13+
"requires_cli": True,
14+
}
15+
registrar_config = {
16+
"dir": ".gemini/commands",
17+
"format": "toml",
18+
"args": "{{args}}",
19+
"extension": ".toml",
20+
}
21+
context_file = "GEMINI.md"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md
2+
#
3+
# Thin wrapper that delegates to the shared update-agent-context script.
4+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
5+
#
6+
# Until then, this delegates to the shared script as a subprocess.
7+
8+
$ErrorActionPreference = 'Stop'
9+
10+
# Derive repo root from script location (walks up to find .specify/)
11+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
12+
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
13+
# If git did not return a repo root, or the git root does not contain .specify,
14+
# fall back to walking up from the script directory to find the initialized project root.
15+
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
16+
$repoRoot = $scriptDir
17+
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
18+
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
19+
$repoRoot = Split-Path -Parent $repoRoot
20+
}
21+
}
22+
23+
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# update-context.sh — Gemini CLI integration: create/update GEMINI.md
3+
#
4+
# Thin wrapper that delegates to the shared update-agent-context script.
5+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
6+
#
7+
# Until then, this delegates to the shared script as a subprocess.
8+
9+
set -euo pipefail
10+
11+
# Derive repo root from script location (walks up to find .specify/)
12+
_script_dir="$(cd "$(dirname "$0")" && pwd)"
13+
_root="$_script_dir"
14+
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
15+
if [ -z "${REPO_ROOT:-}" ]; then
16+
if [ -d "$_root/.specify" ]; then
17+
REPO_ROOT="$_root"
18+
else
19+
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
20+
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
21+
REPO_ROOT="$git_root"
22+
else
23+
REPO_ROOT="$_root"
24+
fi
25+
fi
26+
fi
27+
28+
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Tabnine CLI integration."""
2+
3+
from ..base import TomlIntegration
4+
5+
6+
class TabnineIntegration(TomlIntegration):
7+
key = "tabnine"
8+
config = {
9+
"name": "Tabnine CLI",
10+
"folder": ".tabnine/agent/",
11+
"commands_subdir": "commands",
12+
"install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli",
13+
"requires_cli": True,
14+
}
15+
registrar_config = {
16+
"dir": ".tabnine/agent/commands",
17+
"format": "toml",
18+
"args": "{{args}}",
19+
"extension": ".toml",
20+
}
21+
context_file = "TABNINE.md"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md
2+
#
3+
# Thin wrapper that delegates to the shared update-agent-context script.
4+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
5+
#
6+
# Until then, this delegates to the shared script as a subprocess.
7+
8+
$ErrorActionPreference = 'Stop'
9+
10+
# Derive repo root from script location (walks up to find .specify/)
11+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
12+
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
13+
# If git did not return a repo root, or the git root does not contain .specify,
14+
# fall back to walking up from the script directory to find the initialized project root.
15+
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
16+
$repoRoot = $scriptDir
17+
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
18+
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
19+
$repoRoot = Split-Path -Parent $repoRoot
20+
}
21+
}
22+
23+
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# update-context.sh — Tabnine CLI integration: create/update TABNINE.md
3+
#
4+
# Thin wrapper that delegates to the shared update-agent-context script.
5+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
6+
#
7+
# Until then, this delegates to the shared script as a subprocess.
8+
9+
set -euo pipefail
10+
11+
# Derive repo root from script location (walks up to find .specify/)
12+
_script_dir="$(cd "$(dirname "$0")" && pwd)"
13+
_root="$_script_dir"
14+
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
15+
if [ -z "${REPO_ROOT:-}" ]; then
16+
if [ -d "$_root/.specify" ]; then
17+
REPO_ROOT="$_root"
18+
else
19+
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
20+
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
21+
REPO_ROOT="$git_root"
22+
else
23+
REPO_ROOT="$_root"
24+
fi
25+
fi
26+
fi
27+
28+
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine

0 commit comments

Comments
 (0)