|
5 | 5 | - ``IntegrationBase`` — abstract base every integration must implement. |
6 | 6 | - ``MarkdownIntegration`` — concrete base for standard Markdown-format |
7 | 7 | 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). |
8 | 10 | """ |
9 | 11 |
|
10 | 12 | from __future__ import annotations |
@@ -498,3 +500,136 @@ def setup( |
498 | 500 |
|
499 | 501 | created.extend(self.install_scripts(project_root, manifest)) |
500 | 502 | 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 |
0 commit comments