mirror of
https://github.com/github/spec-kit.git
synced 2026-04-02 02:33:08 +00:00
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.
636 lines
23 KiB
Python
636 lines
23 KiB
Python
"""Base classes for AI-assistant integrations.
|
|
|
|
Provides:
|
|
- ``IntegrationOption`` — declares a CLI option an integration accepts.
|
|
- ``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
|
|
|
|
import re
|
|
import shutil
|
|
from abc import ABC
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from .manifest import IntegrationManifest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IntegrationOption
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class IntegrationOption:
|
|
"""Declares an option that an integration accepts via ``--integration-options``.
|
|
|
|
Attributes:
|
|
name: The flag name (e.g. ``"--commands-dir"``).
|
|
is_flag: ``True`` for boolean flags (``--skills``).
|
|
required: ``True`` if the option must be supplied.
|
|
default: Default value when not supplied (``None`` → no default).
|
|
help: One-line description shown in ``specify integrate info``.
|
|
"""
|
|
|
|
name: str
|
|
is_flag: bool = False
|
|
required: bool = False
|
|
default: Any = None
|
|
help: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IntegrationBase — abstract base class
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class IntegrationBase(ABC):
|
|
"""Abstract base class every integration must implement.
|
|
|
|
Subclasses must set the following class-level attributes:
|
|
|
|
* ``key`` — unique identifier, matches actual CLI tool name
|
|
* ``config`` — dict compatible with ``AGENT_CONFIG`` entries
|
|
* ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS``
|
|
|
|
And may optionally set:
|
|
|
|
* ``context_file`` — path (relative to project root) of the agent
|
|
context/instructions file (e.g. ``"CLAUDE.md"``)
|
|
"""
|
|
|
|
# -- Must be set by every subclass ------------------------------------
|
|
|
|
key: str = ""
|
|
"""Unique integration key — should match the actual CLI tool name."""
|
|
|
|
config: dict[str, Any] | None = None
|
|
"""Metadata dict matching the ``AGENT_CONFIG`` shape."""
|
|
|
|
registrar_config: dict[str, Any] | None = None
|
|
"""Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape."""
|
|
|
|
# -- Optional ---------------------------------------------------------
|
|
|
|
context_file: str | None = None
|
|
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
|
|
|
|
# -- Public API -------------------------------------------------------
|
|
|
|
@classmethod
|
|
def options(cls) -> list[IntegrationOption]:
|
|
"""Return options this integration accepts. Default: none."""
|
|
return []
|
|
|
|
# -- Primitives — building blocks for setup() -------------------------
|
|
|
|
def shared_commands_dir(self) -> Path | None:
|
|
"""Return path to the shared command templates directory.
|
|
|
|
Checks ``core_pack/commands/`` (wheel install) first, then
|
|
``templates/commands/`` (source checkout). Returns ``None``
|
|
if neither exists.
|
|
"""
|
|
import inspect
|
|
|
|
pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent
|
|
for candidate in [
|
|
pkg_dir / "core_pack" / "commands",
|
|
pkg_dir.parent.parent / "templates" / "commands",
|
|
]:
|
|
if candidate.is_dir():
|
|
return candidate
|
|
return None
|
|
|
|
def shared_templates_dir(self) -> Path | None:
|
|
"""Return path to the shared page templates directory.
|
|
|
|
Contains ``vscode-settings.json``, ``spec-template.md``, etc.
|
|
Checks ``core_pack/templates/`` then ``templates/``.
|
|
"""
|
|
import inspect
|
|
|
|
pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent
|
|
for candidate in [
|
|
pkg_dir / "core_pack" / "templates",
|
|
pkg_dir.parent.parent / "templates",
|
|
]:
|
|
if candidate.is_dir():
|
|
return candidate
|
|
return None
|
|
|
|
def list_command_templates(self) -> list[Path]:
|
|
"""Return sorted list of command template files from the shared directory."""
|
|
cmd_dir = self.shared_commands_dir()
|
|
if not cmd_dir or not cmd_dir.is_dir():
|
|
return []
|
|
return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md")
|
|
|
|
def command_filename(self, template_name: str) -> str:
|
|
"""Return the destination filename for a command template.
|
|
|
|
*template_name* is the stem of the source file (e.g. ``"plan"``).
|
|
Default: ``speckit.{template_name}.md``. Subclasses override
|
|
to change the extension or naming convention.
|
|
"""
|
|
return f"speckit.{template_name}.md"
|
|
|
|
def commands_dest(self, project_root: Path) -> Path:
|
|
"""Return the absolute path to the commands output directory.
|
|
|
|
Derived from ``config["folder"]`` and ``config["commands_subdir"]``.
|
|
Raises ``ValueError`` if ``config`` or ``folder`` is missing.
|
|
"""
|
|
if not self.config:
|
|
raise ValueError(
|
|
f"{type(self).__name__}.config is not set; integration "
|
|
"subclasses must define a non-empty 'config' mapping."
|
|
)
|
|
folder = self.config.get("folder")
|
|
if not folder:
|
|
raise ValueError(
|
|
f"{type(self).__name__}.config is missing required 'folder' entry."
|
|
)
|
|
subdir = self.config.get("commands_subdir", "commands")
|
|
return project_root / folder / subdir
|
|
|
|
# -- File operations — granular primitives for setup() ----------------
|
|
|
|
@staticmethod
|
|
def copy_command_to_directory(
|
|
src: Path,
|
|
dest_dir: Path,
|
|
filename: str,
|
|
) -> Path:
|
|
"""Copy a command template to *dest_dir* with the given *filename*.
|
|
|
|
Creates *dest_dir* if needed. Returns the absolute path of the
|
|
written file. The caller can post-process the file before
|
|
recording it in the manifest.
|
|
"""
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dst = dest_dir / filename
|
|
shutil.copy2(src, dst)
|
|
return dst
|
|
|
|
@staticmethod
|
|
def record_file_in_manifest(
|
|
file_path: Path,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
) -> None:
|
|
"""Hash *file_path* and record it in *manifest*.
|
|
|
|
*file_path* must be inside *project_root*.
|
|
"""
|
|
rel = file_path.resolve().relative_to(project_root.resolve())
|
|
manifest.record_existing(rel)
|
|
|
|
@staticmethod
|
|
def write_file_and_record(
|
|
content: str,
|
|
dest: Path,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
) -> Path:
|
|
"""Write *content* to *dest*, hash it, and record in *manifest*.
|
|
|
|
Creates parent directories as needed. Returns *dest*.
|
|
"""
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(content, encoding="utf-8")
|
|
rel = dest.resolve().relative_to(project_root.resolve())
|
|
manifest.record_existing(rel)
|
|
return dest
|
|
|
|
def integration_scripts_dir(self) -> Path | None:
|
|
"""Return path to this integration's bundled ``scripts/`` directory.
|
|
|
|
Looks for a ``scripts/`` sibling of the module that defines the
|
|
concrete subclass (not ``IntegrationBase`` itself).
|
|
Returns ``None`` if the directory doesn't exist.
|
|
"""
|
|
import inspect
|
|
|
|
cls_file = inspect.getfile(type(self))
|
|
scripts = Path(cls_file).resolve().parent / "scripts"
|
|
return scripts if scripts.is_dir() else None
|
|
|
|
def install_scripts(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
) -> list[Path]:
|
|
"""Copy integration-specific scripts into the project.
|
|
|
|
Copies files from this integration's ``scripts/`` directory to
|
|
``.specify/integrations/<key>/scripts/`` in the project. Shell
|
|
scripts are made executable. All copied files are recorded in
|
|
*manifest*.
|
|
|
|
Returns the list of files created.
|
|
"""
|
|
scripts_src = self.integration_scripts_dir()
|
|
if not scripts_src:
|
|
return []
|
|
|
|
created: list[Path] = []
|
|
scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts"
|
|
scripts_dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
for src_script in sorted(scripts_src.iterdir()):
|
|
if not src_script.is_file():
|
|
continue
|
|
dst_script = scripts_dest / src_script.name
|
|
shutil.copy2(src_script, dst_script)
|
|
if dst_script.suffix == ".sh":
|
|
dst_script.chmod(dst_script.stat().st_mode | 0o111)
|
|
self.record_file_in_manifest(dst_script, project_root, manifest)
|
|
created.append(dst_script)
|
|
|
|
return created
|
|
|
|
@staticmethod
|
|
def process_template(
|
|
content: str,
|
|
agent_name: str,
|
|
script_type: str,
|
|
arg_placeholder: str = "$ARGUMENTS",
|
|
) -> str:
|
|
"""Process a raw command template into agent-ready content.
|
|
|
|
Performs the same transformations as the release script:
|
|
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
|
|
2. Replace ``{SCRIPT}`` with the extracted script command
|
|
3. Extract ``agent_scripts.<script_type>`` and replace ``{AGENT_SCRIPT}``
|
|
4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter
|
|
5. Replace ``{ARGS}`` with *arg_placeholder*
|
|
6. Replace ``__AGENT__`` with *agent_name*
|
|
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
|
"""
|
|
# 1. Extract script command from frontmatter
|
|
script_command = ""
|
|
script_pattern = re.compile(
|
|
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
|
|
)
|
|
# Find the scripts: block
|
|
in_scripts = False
|
|
for line in content.splitlines():
|
|
if line.strip() == "scripts:":
|
|
in_scripts = True
|
|
continue
|
|
if in_scripts and line and not line[0].isspace():
|
|
in_scripts = False
|
|
if in_scripts:
|
|
m = script_pattern.match(line)
|
|
if m:
|
|
script_command = m.group(1).strip()
|
|
break
|
|
|
|
# 2. Replace {SCRIPT}
|
|
if script_command:
|
|
content = content.replace("{SCRIPT}", script_command)
|
|
|
|
# 3. Extract agent_script command
|
|
agent_script_command = ""
|
|
in_agent_scripts = False
|
|
for line in content.splitlines():
|
|
if line.strip() == "agent_scripts:":
|
|
in_agent_scripts = True
|
|
continue
|
|
if in_agent_scripts and line and not line[0].isspace():
|
|
in_agent_scripts = False
|
|
if in_agent_scripts:
|
|
m = script_pattern.match(line)
|
|
if m:
|
|
agent_script_command = m.group(1).strip()
|
|
break
|
|
|
|
if agent_script_command:
|
|
content = content.replace("{AGENT_SCRIPT}", agent_script_command)
|
|
|
|
# 4. Strip scripts: and agent_scripts: sections from frontmatter
|
|
lines = content.splitlines(keepends=True)
|
|
output_lines: list[str] = []
|
|
in_frontmatter = False
|
|
skip_section = False
|
|
dash_count = 0
|
|
for line in lines:
|
|
stripped = line.rstrip("\n\r")
|
|
if stripped == "---":
|
|
dash_count += 1
|
|
if dash_count == 1:
|
|
in_frontmatter = True
|
|
else:
|
|
in_frontmatter = False
|
|
skip_section = False
|
|
output_lines.append(line)
|
|
continue
|
|
if in_frontmatter:
|
|
if stripped in ("scripts:", "agent_scripts:"):
|
|
skip_section = True
|
|
continue
|
|
if skip_section:
|
|
if line[0:1].isspace():
|
|
continue # skip indented content under scripts/agent_scripts
|
|
skip_section = False
|
|
output_lines.append(line)
|
|
content = "".join(output_lines)
|
|
|
|
# 5. Replace {ARGS}
|
|
content = content.replace("{ARGS}", arg_placeholder)
|
|
|
|
# 6. Replace __AGENT__
|
|
content = content.replace("__AGENT__", agent_name)
|
|
|
|
# 7. Rewrite paths — delegate to the shared implementation in
|
|
# CommandRegistrar so extension-local paths are preserved and
|
|
# boundary rules stay consistent across the codebase.
|
|
from specify_cli.agents import CommandRegistrar
|
|
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
|
|
|
return content
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install integration command files into *project_root*.
|
|
|
|
Returns the list of files created. Copies raw templates without
|
|
processing. Integrations that need placeholder replacement
|
|
(e.g. ``{SCRIPT}``, ``__AGENT__``) should override ``setup()``
|
|
and call ``process_template()`` in their own loop — see
|
|
``CopilotIntegration`` for an example.
|
|
"""
|
|
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
|
|
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
dst_name = self.command_filename(src_file.stem)
|
|
dst_file = self.copy_command_to_directory(src_file, dest, dst_name)
|
|
self.record_file_in_manifest(dst_file, project_root, manifest)
|
|
created.append(dst_file)
|
|
|
|
return created
|
|
|
|
def teardown(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
*,
|
|
force: bool = False,
|
|
) -> tuple[list[Path], list[Path]]:
|
|
"""Uninstall integration files from *project_root*.
|
|
|
|
Delegates to ``manifest.uninstall()`` which only removes files
|
|
whose hash still matches the recorded value (unless *force*).
|
|
|
|
Returns ``(removed, skipped)`` file lists.
|
|
"""
|
|
return manifest.uninstall(project_root, force=force)
|
|
|
|
# -- Convenience helpers for subclasses -------------------------------
|
|
|
|
def install(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""High-level install — calls ``setup()`` and returns created files."""
|
|
return self.setup(
|
|
project_root, manifest, parsed_options=parsed_options, **opts
|
|
)
|
|
|
|
def uninstall(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
*,
|
|
force: bool = False,
|
|
) -> tuple[list[Path], list[Path]]:
|
|
"""High-level uninstall — calls ``teardown()``."""
|
|
return self.teardown(project_root, manifest, force=force)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MarkdownIntegration — covers ~20 standard agents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MarkdownIntegration(IntegrationBase):
|
|
"""Concrete base for integrations that use standard Markdown commands.
|
|
|
|
Subclasses only need to set ``key``, ``config``, ``registrar_config``
|
|
(and optionally ``context_file``). Everything else is inherited.
|
|
|
|
``setup()`` processes command templates (replacing ``{SCRIPT}``,
|
|
``{ARGS}``, ``__AGENT__``, rewriting paths) and installs
|
|
integration-specific scripts (``update-context.sh`` / ``.ps1``).
|
|
"""
|
|
|
|
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", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
raw = src_file.read_text(encoding="utf-8")
|
|
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
|
dst_name = self.command_filename(src_file.stem)
|
|
dst_file = self.write_file_and_record(
|
|
processed, dest / dst_name, project_root, manifest
|
|
)
|
|
created.append(dst_file)
|
|
|
|
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
|