From 919c7f433479f54a020ce80e018c8a0765ad1862 Mon Sep 17 00:00:00 2001 From: Checkmate <2174084306@qq.com> Date: Wed, 2 Sep 2026 23:35:02 +0800 Subject: [PATCH] fix(integrations): atomically record generated skill files --- src/specify_cli/integrations/base.py | 3639 ++++++++--------- tests/integrations/test_integration_claude.py | 1911 ++++----- 2 files changed, 2778 insertions(+), 2772 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 27c43582b0..ab4d854129 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1,1827 +1,1824 @@ -"""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). -- ``SkillsIntegration`` — concrete base for integrations that install - commands as agent skills (``speckit-/SKILL.md`` layout). -""" - -from __future__ import annotations - -import os -import platform -import re -import shlex -import shutil -import subprocess -import sys -from abc import ABC -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import yaml - -from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent -from .._toml_string import escape_toml_basic as _escape_toml_basic -from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control -from ..events import install_integration_events, remove_integration_events - -if TYPE_CHECKING: - from .manifest import IntegrationManifest - -_HOOK_COMMAND_NOTE = ( - "- When constructing command invocations from hook command names, " - "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" -) - -_CORE_COMMAND_TEMPLATE_ORDER = ( - "analyze", - "clarify", - "constitution", - "implement", - "converge", - "plan", - "checklist", - "specify", - "tasks", - "taskstoissues", -) -_CORE_COMMAND_TEMPLATE_RANK = { - command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER) -} - - -def yaml_quote(value: str) -> str: - """Emit *value* as a double-quoted YAML scalar on a single line. - - A hand-rolled quote cannot carry raw newlines (YAML folds them to - spaces) or control characters (the reader rejects them), so let the - YAML emitter produce the escapes. - """ - return yaml.safe_dump( - str(value), default_style='"', allow_unicode=True, width=sys.maxsize - ).strip() - - -# --------------------------------------------------------------------------- -# 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: - - * ``invoke_separator`` — slash-command separator (defaults to ``"."``) - * ``multi_install_safe`` — declare the integration safe to install - alongside others (defaults to ``False``) - """ - - # -- 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 --------------------------------------------------------- - - invoke_separator: str = "." - """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" - - dev_no_symlink: bool = False - """Whether dev-mode registration should write files instead of symlinks.""" - - multi_install_safe: bool = False - """Whether this integration is declared safe to install alongside others. - - Safe integrations must use a static, unique agent root and command - directory. Registry tests enforce those invariants for every - integration that sets this flag. - """ - - legacy_flat_command_dir: str | None = None - """Previous flat command directory retired after skill replacements exist.""" - - legacy_flat_command_extension: str | None = None - """File extension used by commands in ``legacy_flat_command_dir``.""" - - def post_process_command_content(self, content: str) -> str: - """Transform command content after format rendering. - - Called by ``register_commands()`` for non-skills format types - (Markdown, TOML, YAML) after the command has been rendered into - its target format and before writing to disk. Skills-format - agents use ``post_process_skill_content()`` instead. - - Subclasses may override to inject agent-specific content. - The default implementation returns *content* unchanged. - """ - return content - - # -- Public API ------------------------------------------------------- - - @classmethod - def options(cls) -> list[IntegrationOption]: - """Return options this integration accepts. Default: none.""" - opts = [] - if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)): - opts.append( - IntegrationOption( - "--events", - is_flag=False, - default="true", - help="Enable/disable runtime events (true|false, default: true)", - ) - ) - return opts - - def effective_invoke_separator( - self, - parsed_options: dict[str, Any] | None = None, - project_root: Path | None = None, - ) -> str: - """Return the invoke separator for the given options. - - Subclasses whose separator depends on runtime options (e.g. - Copilot in ``--skills`` mode) should override this method. - The default implementation ignores *parsed_options* and - *project_root* and returns the class-level ``invoke_separator``. - """ - return self.invoke_separator - - def invoke_separator_for_mode(self, skills_enabled: bool) -> str: - """Command-ref separator given the project's *resolved* skills state. - - Registration paths (extension / preset command rendering) have no CLI - ``parsed_options`` — only the persisted ``ai_skills`` flag — so they - resolve the command-reference separator through this hook rather than - the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which - cannot represent an agent whose separator differs between its skills - and command layouts. - - The default is mode-independent and returns exactly what - ``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the - ``registrar_config`` override if present, else the class-level - ``invoke_separator``), so single-layout agents are unaffected. - Dual-mode agents whose separator depends on the layout (e.g. Bob: - ``-`` for skills, ``.`` for legacy commands) override this. - """ - cfg = self.registrar_config or {} - return cfg.get("invoke_separator", self.invoke_separator) - - def is_skills_mode( - self, - parsed_options: dict[str, Any] | None = None, - project_root: Path | None = None, - ) -> bool: - """Return whether this integration scaffolds skills for these options. - - This is the single, well-defined hook the shared init/install/upgrade - machinery consults to decide whether to persist ``ai_skills=True`` and - render skill invocations. It replaces ad-hoc ``isinstance`` / - ``getattr(self, "_skills_mode", ...)`` probing so an integration's - internal representation never has to leak into shared dispatch code. - - *project_root* is optional context for the ``use`` / ``switch`` / - ``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be - empty: dual-mode integrations can consult the already-installed - on-disk layout to avoid silently migrating an existing project to a - different mode. The default ignores it. - - The default for command-first integrations is skills mode only when - ``--skills`` was requested. - ``SkillsIntegration`` overrides this to return ``True`` by default; - skills-first integrations that expose a legacy opt-out (e.g. Bob) - override it to honor their own flag. - """ - return bool((parsed_options or {}).get("skills")) - - def build_exec_args( - self, - prompt: str, - *, - model: str | None = None, - output_json: bool = True, - ) -> list[str] | None: - """Build CLI arguments for non-interactive execution. - - Returns a list of command-line tokens that will execute *prompt* - non-interactively using this integration's CLI tool, or ``None`` - if the integration does not support CLI dispatch. - - Subclasses for CLI-based integrations should override this. - """ - return None - - def _resolve_executable(self) -> str: - """Return the executable for this integration's CLI tool. - - Checks ``SPECKIT_INTEGRATION__EXECUTABLE`` first, allowing - operators to override the binary path without modifying the - integration configuration — useful when the tool is installed in - a non-standard location or a specific version must be pinned. - Hyphens in the integration key are replaced with underscores and - the key is uppercased so that, for example, ``kiro-cli`` maps to - ``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``. - - Falls back to ``self.key`` when the env var is unset or - whitespace-only so existing behaviour is unchanged. - - See issue #2596. - """ - env_name = ( - f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE" - ) - override = os.environ.get(env_name, "").strip() - return override if override else self.key - - def _apply_extra_args_env_var(self, args: list[str]) -> None: - """Append `SPECKIT_INTEGRATION__EXTRA_ARGS` env-var value to *args*. - - Operators can inject extra CLI flags into the spawned agent - subprocess by setting an env var named for the integration key, - e.g. `SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS="--dangerously-skip-permissions"`. - The `INTEGRATION` segment scopes the variable to this subsystem - so it does not collide with other Spec Kit env-var namespaces. - Hyphens in the integration key are replaced with underscores - and the key is uppercased - (e.g. `kiro-cli` → `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`). - - Useful in CI / non-interactive contexts where the spawned agent - needs flags that change its prompt-handling behaviour. - Default behaviour (env var unset or whitespace-only) is a no-op - — *args* is unchanged. Multi-token values are parsed via - `shlex.split`. - - See issue #2595. - """ - env_name = ( - f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXTRA_ARGS" - ) - extra = os.environ.get(env_name, "").strip() - if not extra: - return - try: - tokens = shlex.split(extra) - except ValueError as exc: - raise ValueError( - f"{env_name} is not parseable as a POSIX-quoted command line " - f"(value: {extra!r}). shlex reported: {exc}. " - f"Use single or double quotes to group multi-word values, e.g. " - f'{env_name}=\'--flag "value with spaces"\'.' - ) from exc - args.extend(tokens) - - def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Build the native slash-command invocation for a Spec Kit command. - - The CLI tools discover and execute commands from installed files - on disk. This method builds the invocation string the CLI - expects — e.g. ``"/speckit.specify my-feature"`` for markdown - agents or ``"/speckit-specify my-feature"`` for skills agents. - - *command_name* may be a full dotted name like - ``"speckit.specify"``, an extension command like - ``"speckit.git.commit"``, or a bare stem like ``"specify"``. - """ - stem = command_name - if stem.startswith("speckit."): - stem = stem[len("speckit."):] - - invocation = f"/speckit.{stem}" - if args: - invocation = f"{invocation} {args}" - return invocation - - def dispatch_command( - self, - command_name: str, - args: str = "", - *, - project_root: Path | None = None, - model: str | None = None, - timeout: int = 600, - stream: bool = True, - ) -> dict[str, Any]: - """Dispatch a Spec Kit command through this integration's CLI. - - By default this builds a slash-command invocation with - ``build_command_invocation()`` and passes that prompt to - ``build_exec_args()`` to construct the CLI command line. - Integrations with custom dispatch behavior can override - ``build_command_invocation()``, ``build_exec_args()``, or - ``dispatch_command()`` directly. - - When *stream* is ``True`` (the default), stdout and stderr are - piped directly to the terminal so the user sees live output. - When ``False``, output is captured and returned in the dict. - - Returns a dict with ``exit_code``, ``stdout``, and ``stderr``. - Raises ``NotImplementedError`` if the integration does not - support CLI dispatch. - """ - import subprocess - - prompt = self.build_command_invocation(command_name, args) - # When streaming to the terminal, request text output so the - # user sees readable output instead of raw JSONL events. - exec_args = self.build_exec_args( - prompt, model=model, output_json=not stream - ) - - if exec_args is None: - msg = ( - f"Integration {self.key!r} does not support CLI dispatch. " - f"Override build_exec_args() to enable it." - ) - raise NotImplementedError(msg) - - # Windows: ``subprocess.run`` calls ``CreateProcess`` which does not - # consult ``PATHEXT``, so a bare command name like ``cursor-agent`` - # that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``. - # Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so - # ``.cmd``/``.bat`` shims work transparently. On POSIX this is a - # no-op for absolute paths and a harmless lookup otherwise. - resolved = shutil.which(exec_args[0]) - if resolved: - exec_args = [resolved, *exec_args[1:]] - - cwd = str(project_root) if project_root else None - - if stream: - # No timeout when streaming — the user sees live output and - # can Ctrl+C at any time. The timeout parameter is only - # applied in the captured (non-streaming) branch below. - try: - result = subprocess.run( - exec_args, - text=True, - cwd=cwd, - ) - except KeyboardInterrupt: - return { - "exit_code": 130, - "stdout": "", - "stderr": "Interrupted by user", - } - return { - "exit_code": result.returncode, - "stdout": "", - "stderr": "", - } - - result = subprocess.run( - exec_args, - capture_output=True, - text=True, - cwd=cwd, - timeout=timeout, - ) - return { - "exit_code": result.returncode, - "stdout": result.stdout, - "stderr": result.stderr, - } - - # -- 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 ordered 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"), - key=lambda f: ( - _CORE_COMMAND_TEMPLATE_RANK.get( - f.stem, len(_CORE_COMMAND_TEMPLATE_ORDER) - ), - f.name, - ), - ) - - 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 stale_cleanup_exclusions(self) -> set[str]: - """Return project-relative paths that upgrade must never stale-delete. - - During ``integration upgrade``, files recorded in a previous manifest - but absent from the freshly written one are treated as stale and - removed. Conditionally-tracked files (e.g. a settings file that the - integration merges into when it already exists, and therefore stops - tracking) would otherwise be deleted even though they are still - managed. Subclasses list such paths here to protect them. - """ - exclusions = set() - if self.supports_events(): - from ..events import events_stale_exclusions - exclusions.update(events_stale_exclusions(self.key)) - return exclusions - - 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, +"""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). +- ``SkillsIntegration`` — concrete base for integrations that install + commands as agent skills (``speckit-/SKILL.md`` layout). +""" + +from __future__ import annotations + +import os +import platform +import re +import shlex +import shutil +import subprocess +import sys +from abc import ABC +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent +from .._toml_string import escape_toml_basic as _escape_toml_basic +from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control +from ..events import install_integration_events, remove_integration_events + +if TYPE_CHECKING: + from .manifest import IntegrationManifest + +_HOOK_COMMAND_NOTE = ( + "- When constructing command invocations from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + +_CORE_COMMAND_TEMPLATE_ORDER = ( + "analyze", + "clarify", + "constitution", + "implement", + "converge", + "plan", + "checklist", + "specify", + "tasks", + "taskstoissues", +) +_CORE_COMMAND_TEMPLATE_RANK = { + command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER) +} + + +def yaml_quote(value: str) -> str: + """Emit *value* as a double-quoted YAML scalar on a single line. + + A hand-rolled quote cannot carry raw newlines (YAML folds them to + spaces) or control characters (the reader rejects them), so let the + YAML emitter produce the escapes. + """ + return yaml.safe_dump( + str(value), default_style='"', allow_unicode=True, width=sys.maxsize + ).strip() + + +# --------------------------------------------------------------------------- +# 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: + + * ``invoke_separator`` — slash-command separator (defaults to ``"."``) + * ``multi_install_safe`` — declare the integration safe to install + alongside others (defaults to ``False``) + """ + + # -- 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 --------------------------------------------------------- + + invoke_separator: str = "." + """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + + dev_no_symlink: bool = False + """Whether dev-mode registration should write files instead of symlinks.""" + + multi_install_safe: bool = False + """Whether this integration is declared safe to install alongside others. + + Safe integrations must use a static, unique agent root and command + directory. Registry tests enforce those invariants for every + integration that sets this flag. + """ + + legacy_flat_command_dir: str | None = None + """Previous flat command directory retired after skill replacements exist.""" + + legacy_flat_command_extension: str | None = None + """File extension used by commands in ``legacy_flat_command_dir``.""" + + def post_process_command_content(self, content: str) -> str: + """Transform command content after format rendering. + + Called by ``register_commands()`` for non-skills format types + (Markdown, TOML, YAML) after the command has been rendered into + its target format and before writing to disk. Skills-format + agents use ``post_process_skill_content()`` instead. + + Subclasses may override to inject agent-specific content. + The default implementation returns *content* unchanged. + """ + return content + + # -- Public API ------------------------------------------------------- + + @classmethod + def options(cls) -> list[IntegrationOption]: + """Return options this integration accepts. Default: none.""" + opts = [] + if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)): + opts.append( + IntegrationOption( + "--events", + is_flag=False, + default="true", + help="Enable/disable runtime events (true|false, default: true)", + ) + ) + return opts + + def effective_invoke_separator( + self, + parsed_options: dict[str, Any] | None = None, + project_root: Path | None = None, + ) -> str: + """Return the invoke separator for the given options. + + Subclasses whose separator depends on runtime options (e.g. + Copilot in ``--skills`` mode) should override this method. + The default implementation ignores *parsed_options* and + *project_root* and returns the class-level ``invoke_separator``. + """ + return self.invoke_separator + + def invoke_separator_for_mode(self, skills_enabled: bool) -> str: + """Command-ref separator given the project's *resolved* skills state. + + Registration paths (extension / preset command rendering) have no CLI + ``parsed_options`` — only the persisted ``ai_skills`` flag — so they + resolve the command-reference separator through this hook rather than + the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which + cannot represent an agent whose separator differs between its skills + and command layouts. + + The default is mode-independent and returns exactly what + ``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the + ``registrar_config`` override if present, else the class-level + ``invoke_separator``), so single-layout agents are unaffected. + Dual-mode agents whose separator depends on the layout (e.g. Bob: + ``-`` for skills, ``.`` for legacy commands) override this. + """ + cfg = self.registrar_config or {} + return cfg.get("invoke_separator", self.invoke_separator) + + def is_skills_mode( + self, + parsed_options: dict[str, Any] | None = None, + project_root: Path | None = None, + ) -> bool: + """Return whether this integration scaffolds skills for these options. + + This is the single, well-defined hook the shared init/install/upgrade + machinery consults to decide whether to persist ``ai_skills=True`` and + render skill invocations. It replaces ad-hoc ``isinstance`` / + ``getattr(self, "_skills_mode", ...)`` probing so an integration's + internal representation never has to leak into shared dispatch code. + + *project_root* is optional context for the ``use`` / ``switch`` / + ``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be + empty: dual-mode integrations can consult the already-installed + on-disk layout to avoid silently migrating an existing project to a + different mode. The default ignores it. + + The default for command-first integrations is skills mode only when + ``--skills`` was requested. + ``SkillsIntegration`` overrides this to return ``True`` by default; + skills-first integrations that expose a legacy opt-out (e.g. Bob) + override it to honor their own flag. + """ + return bool((parsed_options or {}).get("skills")) + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive execution. + + Returns a list of command-line tokens that will execute *prompt* + non-interactively using this integration's CLI tool, or ``None`` + if the integration does not support CLI dispatch. + + Subclasses for CLI-based integrations should override this. + """ + return None + + def _resolve_executable(self) -> str: + """Return the executable for this integration's CLI tool. + + Checks ``SPECKIT_INTEGRATION__EXECUTABLE`` first, allowing + operators to override the binary path without modifying the + integration configuration — useful when the tool is installed in + a non-standard location or a specific version must be pinned. + Hyphens in the integration key are replaced with underscores and + the key is uppercased so that, for example, ``kiro-cli`` maps to + ``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``. + + Falls back to ``self.key`` when the env var is unset or + whitespace-only so existing behaviour is unchanged. + + See issue #2596. + """ + env_name = ( + f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE" + ) + override = os.environ.get(env_name, "").strip() + return override if override else self.key + + def _apply_extra_args_env_var(self, args: list[str]) -> None: + """Append `SPECKIT_INTEGRATION__EXTRA_ARGS` env-var value to *args*. + + Operators can inject extra CLI flags into the spawned agent + subprocess by setting an env var named for the integration key, + e.g. `SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS="--dangerously-skip-permissions"`. + The `INTEGRATION` segment scopes the variable to this subsystem + so it does not collide with other Spec Kit env-var namespaces. + Hyphens in the integration key are replaced with underscores + and the key is uppercased + (e.g. `kiro-cli` → `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`). + + Useful in CI / non-interactive contexts where the spawned agent + needs flags that change its prompt-handling behaviour. + Default behaviour (env var unset or whitespace-only) is a no-op + — *args* is unchanged. Multi-token values are parsed via + `shlex.split`. + + See issue #2595. + """ + env_name = ( + f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXTRA_ARGS" + ) + extra = os.environ.get(env_name, "").strip() + if not extra: + return + try: + tokens = shlex.split(extra) + except ValueError as exc: + raise ValueError( + f"{env_name} is not parseable as a POSIX-quoted command line " + f"(value: {extra!r}). shlex reported: {exc}. " + f"Use single or double quotes to group multi-word values, e.g. " + f'{env_name}=\'--flag "value with spaces"\'.' + ) from exc + args.extend(tokens) + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the native slash-command invocation for a Spec Kit command. + + The CLI tools discover and execute commands from installed files + on disk. This method builds the invocation string the CLI + expects — e.g. ``"/speckit.specify my-feature"`` for markdown + agents or ``"/speckit-specify my-feature"`` for skills agents. + + *command_name* may be a full dotted name like + ``"speckit.specify"``, an extension command like + ``"speckit.git.commit"``, or a bare stem like ``"specify"``. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + invocation = f"/speckit.{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch a Spec Kit command through this integration's CLI. + + By default this builds a slash-command invocation with + ``build_command_invocation()`` and passes that prompt to + ``build_exec_args()`` to construct the CLI command line. + Integrations with custom dispatch behavior can override + ``build_command_invocation()``, ``build_exec_args()``, or + ``dispatch_command()`` directly. + + When *stream* is ``True`` (the default), stdout and stderr are + piped directly to the terminal so the user sees live output. + When ``False``, output is captured and returned in the dict. + + Returns a dict with ``exit_code``, ``stdout``, and ``stderr``. + Raises ``NotImplementedError`` if the integration does not + support CLI dispatch. + """ + import subprocess + + prompt = self.build_command_invocation(command_name, args) + # When streaming to the terminal, request text output so the + # user sees readable output instead of raw JSONL events. + exec_args = self.build_exec_args( + prompt, model=model, output_json=not stream + ) + + if exec_args is None: + msg = ( + f"Integration {self.key!r} does not support CLI dispatch. " + f"Override build_exec_args() to enable it." + ) + raise NotImplementedError(msg) + + # Windows: ``subprocess.run`` calls ``CreateProcess`` which does not + # consult ``PATHEXT``, so a bare command name like ``cursor-agent`` + # that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``. + # Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so + # ``.cmd``/``.bat`` shims work transparently. On POSIX this is a + # no-op for absolute paths and a harmless lookup otherwise. + resolved = shutil.which(exec_args[0]) + if resolved: + exec_args = [resolved, *exec_args[1:]] + + cwd = str(project_root) if project_root else None + + if stream: + # No timeout when streaming — the user sees live output and + # can Ctrl+C at any time. The timeout parameter is only + # applied in the captured (non-streaming) branch below. + try: + result = subprocess.run( + exec_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + # -- 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 ordered 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"), + key=lambda f: ( + _CORE_COMMAND_TEMPLATE_RANK.get( + f.stem, len(_CORE_COMMAND_TEMPLATE_ORDER) + ), + f.name, + ), + ) + + 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 stale_cleanup_exclusions(self) -> set[str]: + """Return project-relative paths that upgrade must never stale-delete. + + During ``integration upgrade``, files recorded in a previous manifest + but absent from the freshly written one are treated as stale and + removed. Conditionally-tracked files (e.g. a settings file that the + integration merges into when it already exists, and therefore stops + tracking) would otherwise be deleted even though they are still + managed. Subclasses list such paths here to protect them. + """ + exclusions = set() + if self.supports_events(): + from ..events import events_stale_exclusions + exclusions.update(events_stale_exclusions(self.key)) + return exclusions + + 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. Writes bytes directly to - avoid platform newline translation (CRLF on Windows). Any - ``\r\n`` sequences in *content* are normalised to ``\n`` before - writing. Returns *dest*. + Uses the manifest as the single write-and-record entry point, so a + generated file cannot be written without its hash being recorded. + Any ``\r\n`` sequences in *content* are normalised to ``\n`` before + writing. Returns the path written by the manifest. """ - dest.parent.mkdir(parents=True, exist_ok=True) normalized = content.replace("\r\n", "\n") - dest.write_bytes(normalized.encode("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//scripts/`` in the project. Shell - (``.sh``) and Python (``.py``) 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 in (".sh", ".py"): - 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 resolve_command_refs( - content: str, separator: str = ".", prefix: str = "/" - ) -> str: - """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. - - Each placeholder encodes a command name in upper-case with - underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, - ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses - *separator* to join the segments: - - * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` - * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` - - *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose - native skills invocation uses dollar-prefixed chat commands. - """ - return re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: prefix - + "speckit" - + separator - + m.group(1).lower().replace("_", separator), - content, - ) - - @staticmethod - def resolve_python_interpreter(project_root: Path | None = None) -> str: - """Resolve a portable Python interpreter command for ``{SCRIPT}``. - - Used to build the invocation string for the ``py`` script type so - that ``.py`` workflow scripts run consistently across platforms - (notably Windows, where ``.py`` files are not directly executable). - - Resolution order: - - 1. A project virtual environment (``.venv``) interpreter, if one - exists under *project_root* (POSIX ``bin/python`` or Windows - ``Scripts/python.exe``). The returned path is **relative to the - project root** (e.g. ``.venv/bin/python``) so generated - ``{SCRIPT}`` invocations stay portable and runnable from the - repo root regardless of where the project lives. - 2. ``python3`` on ``PATH``. - 3. ``python`` on ``PATH``. - - Falls back to the running interpreter (``sys.executable``) when - ``PATH`` resolution fails so the generated command is guaranteed - to work in the current environment, and finally to ``"python3"`` - if even that is unavailable. - """ - if project_root is not None: - # (existence check path, repo-root-relative invocation string) - venv_candidates = ( - (project_root / ".venv" / "bin" / "python", ".venv/bin/python"), - ( - project_root / ".venv" / "Scripts" / "python.exe", - ".venv/Scripts/python.exe", - ), - ) - for candidate, relative in venv_candidates: - if candidate.exists(): - return relative - for name in ("python3", "python"): - found = shutil.which(name) - if not found: - continue - # On Windows, python3/python on PATH may be the Microsoft - # Store App Execution Alias stub: it exists but only prints - # an installer hint and exits non-zero, so existence is not - # enough (see #3304 for the same defect in the sh scripts). - if sys.platform == "win32" and not IntegrationBase._interpreter_runs( - found - ): - continue - return name - return sys.executable or "python3" - - @staticmethod - def build_python_invocation( - script_command: str, project_root: Path | None = None - ) -> str: - """Build a Python script command for the current platform shell.""" - interpreter = IntegrationBase.resolve_python_interpreter(project_root) - if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter): - quoted_interpreter = interpreter.replace("'", "''") - interpreter = f"& '{quoted_interpreter}'" - elif os.name != "nt": - interpreter = shlex.quote(interpreter) - return f"{interpreter} {script_command}" - - @staticmethod - def select_script_variant( - requested: object, script_commands: dict[str, str] - ) -> str: - """Select the requested variant or a runnable platform fallback.""" - if isinstance(requested, str) and requested in script_commands: - return requested - - platform_variant = ( - "ps" if platform.system().lower().startswith("win") else "sh" - ) - secondary_variant = "sh" if platform_variant == "ps" else "ps" - fallbacks = ( - (platform_variant, "py") - if requested == "py" - else (platform_variant, secondary_variant, "py") - ) - for candidate in fallbacks: - if candidate in script_commands: - return candidate - - available = ", ".join(sorted(script_commands)) or "none" - raise ValueError( - "No runnable script variant for this platform: " - f"requested {requested!r}; available: {available}" - ) - - @staticmethod - def _interpreter_runs(path: str) -> bool: - """Return True when *path* executes as a Python interpreter. - - Runs isolated (``-I``) without ``site`` (``-S``) and discards - I/O so the probe is a fast liveness check that cannot trigger - ``sitecustomize``/user startup hooks. - """ - try: - return ( - subprocess.run( - [path, "-I", "-S", "-c", ""], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=15, - ).returncode - == 0 - ) - except (OSError, subprocess.SubprocessError): - return False - - @staticmethod - def process_template( - content: str, - agent_name: str, - script_type: str, - arg_placeholder: str = "$ARGUMENTS", - invoke_separator: str = ".", - project_root: Path | None = None, - ) -> str: - """Process a raw command template into agent-ready content. - - Performs the same transformations as the release script: - 1. Select ``scripts.`` from YAML frontmatter, falling - back to a runnable platform shell or Python variant when unavailable - 2. Replace ``{SCRIPT}`` with the extracted script command - 3. Strip ``scripts:`` section from frontmatter - 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* - 5. Replace ``__AGENT__`` with *agent_name* - 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. - 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings - """ - # 1. Extract script command from frontmatter - script_commands: dict[str, str] = {} - script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$") - # Find the scripts: block - in_frontmatter = False - in_scripts = False - for line in content.splitlines(): - if line == "---": - if in_frontmatter: - break - in_frontmatter = True - continue - if not in_frontmatter: - continue - if line == "scripts:": - in_scripts = True - continue - if in_scripts and line and not line[0].isspace(): - break - if in_scripts: - m = script_pattern.match(line) - if m: - script_commands[m.group(1)] = m.group(2).strip() - - selected_script_type = ( - IntegrationBase.select_script_variant(script_type, script_commands) - if script_commands - else "" - ) - - script_command = script_commands.get(selected_script_type, "") - - # 2. Replace {SCRIPT} - if script_command: - # For the Python script type, prefix the resolved interpreter so - # the command is portable (``.py`` files are not directly - # executable on Windows). - if selected_script_type == "py": - script_command = IntegrationBase.build_python_invocation( - script_command, project_root - ) - content = content.replace("{SCRIPT}", script_command) - - # 3. Strip scripts: section 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 == "scripts:": - skip_section = True - continue - if skip_section: - if line[0:1].isspace(): - continue # skip indented content under scripts - skip_section = False - output_lines.append(line) - content = "".join(output_lines) - - # 4. Replace {ARGS} and $ARGUMENTS - content = content.replace("{ARGS}", arg_placeholder) - content = content.replace("$ARGUMENTS", arg_placeholder) - - # 5. Replace __AGENT__ - content = content.replace("__AGENT__", agent_name) - - # 6. 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) - - # 8. Replace __SPECKIT_COMMAND___ with invocation strings - invocation_prefix = get_invocation_prefix( - agent_name, invoke_separator == "-" - ) - content = IntegrationBase.resolve_command_refs( - content, invoke_separator, invocation_prefix - ) - - 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. - """ - self.remove_events(project_root, manifest) - return manifest.uninstall(project_root, force=force) - - def emit_events( - self, - project_root: Path, - manifest: IntegrationManifest, - events: dict[str, dict[str, Any]] | None = None, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Emit native event configuration for this integration.""" - return install_integration_events(self, project_root, manifest, events or {}) - - def remove_events( - self, - project_root: Path, - manifest: IntegrationManifest, - ) -> None: - """Remove Specify-authored event entries from native config.""" - remove_integration_events(self, project_root, manifest) - - def supports_events(self) -> bool: - """Return True if this integration supports agent-native events.""" - return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None)) - - # Context-injection envelope for hook stdout, keyed by canonical event - # (with "*" as the fallback). Not every agent injects a hook's plain-text - # stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only - # protocols (plain text becomes user-facing noise), Copilot discards - # non-JSON stdout, and Cursor parses stdout as JSON. Values: - # "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}} - # "additionalContext" → {"additionalContext": ...} (top-level, Copilot) - # "additional_context" → {"additional_context": ...} (top-level, Cursor) - # "suppress" → emit nothing (strict-JSON agents on events whose - # output can't be used) - # Absent (no matching key and no "*") → plain stdout passthrough - # (Claude/Codex inject plain stdout; opencode injects via its TS plugin). - events_context_envelope: dict[str, str] = {} - - # -- 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``. - Everything else is inherited. - - ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths). - """ - - def build_exec_args( - self, - prompt: str, - *, - model: str | None = None, - output_json: bool = True, - ) -> list[str] | None: - if not self.config or not self.config.get("requires_cli"): - return None - args = [self._resolve_executable(), "-p", prompt] - self._apply_extra_args_env_var(args) - if model: - args.extend(["--model", model]) - if output_json: - args.extend(["--output-format", "json"]) - return args - - 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, - project_root=project_root, - ) - 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) - - - # Install agent runtime events - event_files = self.emit_events( - project_root, manifest, events=opts.get("events"), parsed_options=parsed_options - ) - created.extend(event_files) - - 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``. 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 build_exec_args( - self, - prompt: str, - *, - model: str | None = None, - output_json: bool = True, - ) -> list[str] | None: - if not self.config or not self.config.get("requires_cli"): - return None - args = [self._resolve_executable(), "-p", prompt] - self._apply_extra_args_env_var(args) - if model: - args.extend(["-m", model]) - if output_json: - args.extend(["--output-format", "json"]) - return args - - 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. - - Parses the YAML frontmatter so block scalar descriptions (``|`` - and ``>``) keep their YAML semantics instead of being treated as - raw text. - """ - - frontmatter_text, _ = TomlIntegration._split_frontmatter(content) - if not frontmatter_text: - return "" - try: - frontmatter = yaml.safe_load(frontmatter_text) or {} - except yaml.YAMLError: - return "" - - if not isinstance(frontmatter, dict): - return "" - - description = frontmatter.get("description", "") - if isinstance(description, str): - return description - return "" - - @staticmethod - def _split_frontmatter(content: str) -> tuple[str, str]: - """Split YAML frontmatter from the remaining content. - - Returns ``("", content)`` when no complete frontmatter block is - present. The body is preserved exactly as written so prompt text - keeps its intended formatting. - """ - if not content.startswith("---"): - return "", content - - lines = content.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return "", content - - frontmatter_end = -1 - for i, line in enumerate(lines[1:], start=1): - if line.rstrip("\r\n") == "---": - frontmatter_end = i - break - - if frontmatter_end == -1: - return "", content - - frontmatter = "".join(lines[1:frontmatter_end]) - body = "".join(lines[frontmatter_end + 1 :]) - return frontmatter, body - - # Control-char detection and basic-string escaping are shared with the - # extension/preset renderer in ``specify_cli.agents`` via - # ``specify_cli._toml_string`` so the two never drift apart. - _has_illegal_toml_control = staticmethod(_has_illegal_toml_control) - _escape_toml_basic = staticmethod(_escape_toml_basic) - - @staticmethod - def _render_toml_string(value: str) -> str: - """Render *value* as a TOML string literal. - - Uses a basic string for single-line values, multiline basic - strings for values containing newlines, and falls back to a - literal string or escaped basic string when delimiters appear in - the content. - """ - # Control characters other than tab/newline (and a bare CR) cannot - # appear literally in any TOML string; route them to a fully-escaped - # basic string so the generated file stays parseable. - if TomlIntegration._has_illegal_toml_control(value): - return TomlIntegration._escape_toml_basic(value) - - if "\n" not in value and "\r" not in value: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - escaped = value.replace("\\", "\\\\") - if '"""' not in escaped: - if escaped.endswith('"'): - return '"""\n' + escaped + '\\\n"""' - return '"""\n' + escaped + '"""' - if "'''" not in value and not value.endswith("'"): - return "'''\n" + value + "'''" - - return TomlIntegration._escape_toml_basic(value) - - @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("\\n")``'d before rendering, so the TOML - value preserves content without forcing a trailing newline. As a - result, multiline delimiters appear on their own line only when - the rendered value itself ends with a newline. - """ - toml_lines: list[str] = [] - - if description: - toml_lines.append( - f"description = {TomlIntegration._render_toml_string(description)}" - ) - toml_lines.append("") - - body = body.rstrip("\n") - toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(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, - project_root=project_root, - ) - _, body = self._split_frontmatter(processed) - toml_content = self._render_toml(description, body) - 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) - - - # Install agent runtime events - event_files = self.emit_events( - project_root, manifest, events=opts.get("events"), parsed_options=parsed_options - ) - created.extend(event_files) - - return created - - -# --------------------------------------------------------------------------- -# YamlIntegration — YAML-format agents (Goose) -# --------------------------------------------------------------------------- - -# Characters a YAML literal block scalar cannot carry: C0 controls other -# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the -# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF. -# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1 -# treats it as a line break, which corrupts the block scalar's structure -# just the same, so all three are included. -_YAML_BLOCK_SCALAR_UNSAFE = re.compile( - r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]" -) - - -class YamlIntegration(IntegrationBase): - """Concrete base for integrations that use YAML recipe format. - - Mirrors ``TomlIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config``. Everything else is inherited. - - ``setup()`` processes command templates through the same placeholder - pipeline as ``MarkdownIntegration``, then converts the result to - YAML recipe format (version, title, description, prompt block scalar). - """ - - def command_filename(self, template_name: str) -> str: - """YAML commands use ``.yaml`` extension.""" - return f"speckit.{template_name}.yaml" - - @staticmethod - def _extract_frontmatter(content: str) -> dict[str, Any]: - """Extract frontmatter as a dict from YAML frontmatter block.""" - - if not content.startswith("---"): - return {} - - lines = content.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return {} - - frontmatter_end = -1 - for i, line in enumerate(lines[1:], start=1): - if line.rstrip("\r\n") == "---": - frontmatter_end = i - break - - if frontmatter_end == -1: - return {} - - frontmatter_text = "".join(lines[1:frontmatter_end]) - try: - fm = yaml.safe_load(frontmatter_text) or {} - except yaml.YAMLError: - return {} - - return fm if isinstance(fm, dict) else {} - - @staticmethod - def _split_frontmatter(content: str) -> tuple[str, str]: - """Split YAML frontmatter from the remaining body content.""" - if not content.startswith("---"): - return "", content - - lines = content.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return "", content - - frontmatter_end = -1 - for i, line in enumerate(lines[1:], start=1): - if line.rstrip("\r\n") == "---": - frontmatter_end = i - break - - if frontmatter_end == -1: - return "", content - - frontmatter = "".join(lines[1:frontmatter_end]) - body = "".join(lines[frontmatter_end + 1 :]) - return frontmatter, body - - @staticmethod - def _human_title(identifier: str) -> str: - """Convert an identifier to a human-readable title. - - Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``, - and ``_`` with spaces before title-casing. - """ - text = identifier - if text.startswith("speckit."): - text = text[len("speckit.") :] - return text.replace(".", " ").replace("-", " ").replace("_", " ").title() - - - @classmethod - def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]: - """Build the base YAML header.""" - header = { - "version": "1.0.0", - "title": title, - "description": description, - "author": {"contact": "spec-kit"}, - "parameters": [ - { - "key": "args", - "input_type": "string", - "requirement": "optional", - "default": "", - "description": "User input passed to the command.", - } - ], - "extensions": [{"type": "builtin", "name": "developer"}], - "activities": ["Spec-Driven Development"], - } - return header - - @classmethod - def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: - """Render a YAML recipe file from title, description, and body. - - Produces a Goose-compatible recipe with a literal block scalar for - normal prompt content, or an escaped quoted scalar when control - characters require it. Uses ``yaml.safe_dump()`` for the header fields. - """ - header = cls._build_yaml_header(title, description) - - header_yaml = yaml.safe_dump( - header, - sort_keys=False, - allow_unicode=True, - default_flow_style=False, - ).strip() - - # YAML forbids C0 control characters (except tab and newline) and - # DEL in every scalar form, and a bare CR acts as a line break - # inside a block scalar. A literal block scalar emits such bytes - # verbatim, producing a recipe the YAML parser rejects, so fall - # back to an escaped double-quoted scalar for those bodies. - if _YAML_BLOCK_SCALAR_UNSAFE.search(body): - prompt_yaml = yaml.safe_dump( - {"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize - ).strip() - lines = [ - header_yaml, - prompt_yaml, - "", - f"# Source: {source_id}", - ] - return "\n".join(lines) + "\n" - - # Indent the body for YAML block scalar. Use an explicit indentation - # indicator ("|2") rather than a bare "|": YAML infers a plain block - # scalar's indentation from its first non-empty line, so a body whose - # first line is itself indented (e.g. a markdown code block or a nested - # list item) would make the parser expect that deeper indent for the - # whole block and reject the later, less-indented lines. Pinning the - # indent to 2 keeps the recipe parseable whatever the body looks like. - indented = "\n".join(f" {line}" for line in body.split("\n")) - - lines = [ - header_yaml, - "prompt: |2", - indented, - "", - f"# Source: {source_id}", - ] - - return "\n".join(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") - fm = self._extract_frontmatter(raw) - description = fm.get("description", "") - if not isinstance(description, str): - description = str(description) if description is not None else "" - title = fm.get("title", "") or fm.get("name", "") - if not isinstance(title, str): - title = str(title) if title is not None else "" - if not title: - title = self._human_title(src_file.stem) - - processed = self.process_template( - raw, self.key, script_type, arg_placeholder, - project_root=project_root, - ) - _, body = self._split_frontmatter(processed) - yaml_content = self._render_yaml( - title, description, body, f"templates/commands/{src_file.name}" - ) - dst_name = self.command_filename(src_file.stem) - dst_file = self.write_file_and_record( - yaml_content, dest / dst_name, project_root, manifest - ) - created.append(dst_file) - - - # Install agent runtime events - event_files = self.emit_events( - project_root, manifest, events=opts.get("events"), parsed_options=parsed_options - ) - created.extend(event_files) - - return created - - -# --------------------------------------------------------------------------- -# SkillsIntegration — skills-format agents (Codex, Kimi, Agy) -# --------------------------------------------------------------------------- - - -class SkillsIntegration(IntegrationBase): - """Concrete base for integrations that install commands as agent skills. - - Skills use the ``speckit-/SKILL.md`` directory layout following - the `agentskills.io `_ spec. - - Subclasses set ``key``, ``config``, ``registrar_config`` like any - integration. They may also - override ``options()`` to declare additional CLI flags (e.g. - ``--skills``, ``--migrate-legacy``). - - ``setup()`` processes each shared command template into a - ``speckit-/SKILL.md`` file with skills-oriented frontmatter. - """ - - invoke_separator = "-" - - def is_skills_mode( - self, - parsed_options: dict[str, Any] | None = None, - project_root: Path | None = None, - ) -> bool: - """Skills-native integrations scaffold skills unconditionally.""" - return True - - def build_exec_args( - self, - prompt: str, - *, - model: str | None = None, - output_json: bool = True, - ) -> list[str] | None: - if not self.config or not self.config.get("requires_cli"): - return None - args = [self._resolve_executable(), "-p", prompt] - self._apply_extra_args_env_var(args) - if model: - args.extend(["--model", model]) - if output_json: - args.extend(["--output-format", "json"]) - return args - - def skills_dest(self, project_root: Path) -> Path: - """Return the absolute path to the skills output directory. - - Derived from ``config["folder"]`` and the configured - ``commands_subdir`` (defaults to ``"skills"``). - - Raises ``ValueError`` when ``config`` or ``folder`` is missing. - """ - if not self.config: - raise ValueError(f"{type(self).__name__}.config is not set.") - 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", "skills") - return project_root / folder / subdir - - def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Build the agent's native invocation for a hyphenated skill name.""" - stem = command_name - if stem.startswith("speckit."): - stem = stem[len("speckit."):] - - prefix = "$" if is_dollar_skills_agent(self.key, True) else "/" - invocation = prefix + "speckit-" + stem.replace(".", "-") - if args: - invocation = f"{invocation} {args}" - return invocation - - @staticmethod - def _inject_hook_command_note( - content: str, invocation_prefix: str = "/" - ) -> str: - """Insert a dot-to-hyphen note before each hook output instruction. - - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. - Skips individual instructions that already have the note immediately - above them. - """ - note = _HOOK_COMMAND_NOTE.rstrip("\n") - if invocation_prefix != "/": - note = note.replace( - "`/speckit-git-commit`", - f"`{invocation_prefix}speckit-git-commit`", - ) - - def repl(m: re.Match[str]) -> str: - indent = m.group(1) - instruction = m.group(2) - previous_lines = content[:m.start()].splitlines() - if previous_lines and previous_lines[-1] == indent + note: - return m.group(0) - # ``eol`` is empty when the regex matched via ``$`` because the - # instruction was the final line of a file with no trailing - # newline. Default to ``\n`` so the note never collapses onto - # the same line as the instruction. - eol = m.group(3) or "\n" - return ( - indent - + note - + eol - + indent - + instruction - + eol - ) - - return re.sub( - r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", - repl, - content, - ) - - def post_process_skill_content(self, content: str) -> str: - """Post-process a SKILL.md file's content after generation. - - Called by external skill generators (presets, extensions) to let - the integration inject agent-specific frontmatter or body - transformations. The base implementation injects shared skills - guidance for converting dotted hook command names to the agent-native - hyphenated command invocation (e.g. ``/speckit-git-commit`` or - ``$speckit-git-commit``). Subclasses may override -- see - ``ClaudeIntegration``. - """ - invocation_prefix = get_invocation_prefix(self.key, True) - return self._inject_hook_command_note(content, invocation_prefix) - - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: - """Install command templates as agent skills. - - Creates ``speckit-/SKILL.md`` for each shared command - template. Each SKILL.md has normalised frontmatter containing - ``name``, ``description``, ``compatibility``, and ``metadata``. - """ - - 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})" - ) - - skills_dir = self.skills_dest(project_root).resolve() - try: - skills_dir.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Skills destination {skills_dir} escapes " - f"project root {project_root_resolved}" - ) from exc - - 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") - - # Derive the skill name from the template stem - command_name = src_file.stem # e.g. "plan" - skill_name = f"speckit-{command_name.replace('.', '-')}" - - # Parse frontmatter for description. Locate the closing ``---`` on - # its own line rather than with ``raw.split("---", 2)`` — a bare - # substring split stops at the first ``---`` *anywhere*, including - # one inside a value such as ``description: Separate sections - # with ---``, which truncates the frontmatter and drops later keys. - # The block between the delimiters is parsed unstripped so trailing - # newlines in literal (``|``) block scalars survive. - frontmatter: dict[str, Any] = {} - if raw.startswith("---"): - fm_lines = raw.splitlines(keepends=True) - fm_close = next( - ( - i - for i in range(1, len(fm_lines)) - if fm_lines[i].rstrip() == "---" - ), - None, - ) - if fm_close is not None: - try: - fm = yaml.safe_load("".join(fm_lines[1:fm_close])) - if isinstance(fm, dict): - frontmatter = fm - except yaml.YAMLError: - pass - - # Process body through the standard template pipeline - processed_body = self.process_template( - raw, self.key, script_type, arg_placeholder, - project_root=project_root, - invoke_separator=self.invoke_separator, - ) - # Strip the processed frontmatter — we rebuild it for skills. - # Preserve leading whitespace in the body to match release ZIP - # output byte-for-byte (the template body starts with \n after - # the closing ---). Scan for the closing ``---`` on its own line - # rather than ``split("---", 2)`` so a ``---`` embedded in a value - # does not truncate the frontmatter and spill it into the body. - if processed_body.startswith("---"): - body_lines = processed_body.splitlines(keepends=True) - close_idx = next( - ( - i - for i in range(1, len(body_lines)) - if body_lines[i].rstrip() == "---" - ), - None, - ) - if close_idx is not None: - # Keep whatever trails the ``---`` marker on the closing - # line (normally just the newline) so the body stays - # byte-for-byte identical to ``split("---", 2)[2]``. The - # line-anchored check guarantees ``---`` sits at index 0. - processed_body = body_lines[close_idx][3:] + "".join( - body_lines[close_idx + 1 :] - ) - - # Select description — use the original template description - # to stay byte-for-byte identical with release ZIP output. - description = frontmatter.get("description", "") - if not description: - description = f"Spec Kit: {command_name} workflow" - - # Build SKILL.md with manually formatted frontmatter (stable - # double-quoted values). yaml_quote escapes newlines and control - # characters that a plain quoted f-string cannot carry. - skill_content = ( - f"---\n" - f"name: {yaml_quote(skill_name)}\n" - f"description: {yaml_quote(description)}\n" - f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" - f"metadata:\n" - f" author: {yaml_quote('github-spec-kit')}\n" - f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" - f"---\n" - f"{processed_body}" - ) - - skill_content = self.post_process_skill_content(skill_content) - - # Write speckit-/SKILL.md - skill_dir = skills_dir / skill_name - skill_file = skill_dir / "SKILL.md" - dst = self.write_file_and_record( - skill_content, skill_file, project_root, manifest - ) - created.append(dst) - - - # Install agent runtime events - event_files = self.emit_events( - project_root, manifest, events=opts.get("events"), parsed_options=parsed_options - ) - created.extend(event_files) - - return created + return manifest.record_file(rel, normalized) + + 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//scripts/`` in the project. Shell + (``.sh``) and Python (``.py``) 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 in (".sh", ".py"): + 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 resolve_command_refs( + content: str, separator: str = ".", prefix: str = "/" + ) -> str: + """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + + Each placeholder encodes a command name in upper-case with + underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *separator* to join the segments: + + * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` + * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose + native skills invocation uses dollar-prefixed chat commands. + """ + return re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: prefix + + "speckit" + + separator + + m.group(1).lower().replace("_", separator), + content, + ) + + @staticmethod + def resolve_python_interpreter(project_root: Path | None = None) -> str: + """Resolve a portable Python interpreter command for ``{SCRIPT}``. + + Used to build the invocation string for the ``py`` script type so + that ``.py`` workflow scripts run consistently across platforms + (notably Windows, where ``.py`` files are not directly executable). + + Resolution order: + + 1. A project virtual environment (``.venv``) interpreter, if one + exists under *project_root* (POSIX ``bin/python`` or Windows + ``Scripts/python.exe``). The returned path is **relative to the + project root** (e.g. ``.venv/bin/python``) so generated + ``{SCRIPT}`` invocations stay portable and runnable from the + repo root regardless of where the project lives. + 2. ``python3`` on ``PATH``. + 3. ``python`` on ``PATH``. + + Falls back to the running interpreter (``sys.executable``) when + ``PATH`` resolution fails so the generated command is guaranteed + to work in the current environment, and finally to ``"python3"`` + if even that is unavailable. + """ + if project_root is not None: + # (existence check path, repo-root-relative invocation string) + venv_candidates = ( + (project_root / ".venv" / "bin" / "python", ".venv/bin/python"), + ( + project_root / ".venv" / "Scripts" / "python.exe", + ".venv/Scripts/python.exe", + ), + ) + for candidate, relative in venv_candidates: + if candidate.exists(): + return relative + for name in ("python3", "python"): + found = shutil.which(name) + if not found: + continue + # On Windows, python3/python on PATH may be the Microsoft + # Store App Execution Alias stub: it exists but only prints + # an installer hint and exits non-zero, so existence is not + # enough (see #3304 for the same defect in the sh scripts). + if sys.platform == "win32" and not IntegrationBase._interpreter_runs( + found + ): + continue + return name + return sys.executable or "python3" + + @staticmethod + def build_python_invocation( + script_command: str, project_root: Path | None = None + ) -> str: + """Build a Python script command for the current platform shell.""" + interpreter = IntegrationBase.resolve_python_interpreter(project_root) + if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter): + quoted_interpreter = interpreter.replace("'", "''") + interpreter = f"& '{quoted_interpreter}'" + elif os.name != "nt": + interpreter = shlex.quote(interpreter) + return f"{interpreter} {script_command}" + + @staticmethod + def select_script_variant( + requested: object, script_commands: dict[str, str] + ) -> str: + """Select the requested variant or a runnable platform fallback.""" + if isinstance(requested, str) and requested in script_commands: + return requested + + platform_variant = ( + "ps" if platform.system().lower().startswith("win") else "sh" + ) + secondary_variant = "sh" if platform_variant == "ps" else "ps" + fallbacks = ( + (platform_variant, "py") + if requested == "py" + else (platform_variant, secondary_variant, "py") + ) + for candidate in fallbacks: + if candidate in script_commands: + return candidate + + available = ", ".join(sorted(script_commands)) or "none" + raise ValueError( + "No runnable script variant for this platform: " + f"requested {requested!r}; available: {available}" + ) + + @staticmethod + def _interpreter_runs(path: str) -> bool: + """Return True when *path* executes as a Python interpreter. + + Runs isolated (``-I``) without ``site`` (``-S``) and discards + I/O so the probe is a fast liveness check that cannot trigger + ``sitecustomize``/user startup hooks. + """ + try: + return ( + subprocess.run( + [path, "-I", "-S", "-c", ""], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + @staticmethod + def process_template( + content: str, + agent_name: str, + script_type: str, + arg_placeholder: str = "$ARGUMENTS", + invoke_separator: str = ".", + project_root: Path | None = None, + ) -> str: + """Process a raw command template into agent-ready content. + + Performs the same transformations as the release script: + 1. Select ``scripts.`` from YAML frontmatter, falling + back to a runnable platform shell or Python variant when unavailable + 2. Replace ``{SCRIPT}`` with the extracted script command + 3. Strip ``scripts:`` section from frontmatter + 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* + 5. Replace ``__AGENT__`` with *agent_name* + 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings + """ + # 1. Extract script command from frontmatter + script_commands: dict[str, str] = {} + script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$") + # Find the scripts: block + in_frontmatter = False + in_scripts = False + for line in content.splitlines(): + if line == "---": + if in_frontmatter: + break + in_frontmatter = True + continue + if not in_frontmatter: + continue + if line == "scripts:": + in_scripts = True + continue + if in_scripts and line and not line[0].isspace(): + break + if in_scripts: + m = script_pattern.match(line) + if m: + script_commands[m.group(1)] = m.group(2).strip() + + selected_script_type = ( + IntegrationBase.select_script_variant(script_type, script_commands) + if script_commands + else "" + ) + + script_command = script_commands.get(selected_script_type, "") + + # 2. Replace {SCRIPT} + if script_command: + # For the Python script type, prefix the resolved interpreter so + # the command is portable (``.py`` files are not directly + # executable on Windows). + if selected_script_type == "py": + script_command = IntegrationBase.build_python_invocation( + script_command, project_root + ) + content = content.replace("{SCRIPT}", script_command) + + # 3. Strip scripts: section 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 == "scripts:": + skip_section = True + continue + if skip_section: + if line[0:1].isspace(): + continue # skip indented content under scripts + skip_section = False + output_lines.append(line) + content = "".join(output_lines) + + # 4. Replace {ARGS} and $ARGUMENTS + content = content.replace("{ARGS}", arg_placeholder) + content = content.replace("$ARGUMENTS", arg_placeholder) + + # 5. Replace __AGENT__ + content = content.replace("__AGENT__", agent_name) + + # 6. 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) + + # 8. Replace __SPECKIT_COMMAND___ with invocation strings + invocation_prefix = get_invocation_prefix( + agent_name, invoke_separator == "-" + ) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invocation_prefix + ) + + 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. + """ + self.remove_events(project_root, manifest) + return manifest.uninstall(project_root, force=force) + + def emit_events( + self, + project_root: Path, + manifest: IntegrationManifest, + events: dict[str, dict[str, Any]] | None = None, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Emit native event configuration for this integration.""" + return install_integration_events(self, project_root, manifest, events or {}) + + def remove_events( + self, + project_root: Path, + manifest: IntegrationManifest, + ) -> None: + """Remove Specify-authored event entries from native config.""" + remove_integration_events(self, project_root, manifest) + + def supports_events(self) -> bool: + """Return True if this integration supports agent-native events.""" + return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None)) + + # Context-injection envelope for hook stdout, keyed by canonical event + # (with "*" as the fallback). Not every agent injects a hook's plain-text + # stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only + # protocols (plain text becomes user-facing noise), Copilot discards + # non-JSON stdout, and Cursor parses stdout as JSON. Values: + # "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}} + # "additionalContext" → {"additionalContext": ...} (top-level, Copilot) + # "additional_context" → {"additional_context": ...} (top-level, Cursor) + # "suppress" → emit nothing (strict-JSON agents on events whose + # output can't be used) + # Absent (no matching key and no "*") → plain stdout passthrough + # (Claude/Codex inject plain stdout; opencode injects via its TS plugin). + events_context_envelope: dict[str, str] = {} + + # -- 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``. + Everything else is inherited. + + ``setup()`` processes command templates (replacing ``{SCRIPT}``, + ``{ARGS}``, ``__AGENT__``, rewriting paths). + """ + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self._resolve_executable(), "-p", prompt] + self._apply_extra_args_env_var(args) + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + 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, + project_root=project_root, + ) + 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) + + + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options + ) + created.extend(event_files) + + 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``. 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 build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self._resolve_executable(), "-p", prompt] + self._apply_extra_args_env_var(args) + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + 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. + + Parses the YAML frontmatter so block scalar descriptions (``|`` + and ``>``) keep their YAML semantics instead of being treated as + raw text. + """ + + frontmatter_text, _ = TomlIntegration._split_frontmatter(content) + if not frontmatter_text: + return "" + try: + frontmatter = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return "" + + if not isinstance(frontmatter, dict): + return "" + + description = frontmatter.get("description", "") + if isinstance(description, str): + return description + return "" + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining content. + + Returns ``("", content)`` when no complete frontmatter block is + present. The body is preserved exactly as written so prompt text + keeps its intended formatting. + """ + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + # Control-char detection and basic-string escaping are shared with the + # extension/preset renderer in ``specify_cli.agents`` via + # ``specify_cli._toml_string`` so the two never drift apart. + _has_illegal_toml_control = staticmethod(_has_illegal_toml_control) + _escape_toml_basic = staticmethod(_escape_toml_basic) + + @staticmethod + def _render_toml_string(value: str) -> str: + """Render *value* as a TOML string literal. + + Uses a basic string for single-line values, multiline basic + strings for values containing newlines, and falls back to a + literal string or escaped basic string when delimiters appear in + the content. + """ + # Control characters other than tab/newline (and a bare CR) cannot + # appear literally in any TOML string; route them to a fully-escaped + # basic string so the generated file stays parseable. + if TomlIntegration._has_illegal_toml_control(value): + return TomlIntegration._escape_toml_basic(value) + + if "\n" not in value and "\r" not in value: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + escaped = value.replace("\\", "\\\\") + if '"""' not in escaped: + if escaped.endswith('"'): + return '"""\n' + escaped + '\\\n"""' + return '"""\n' + escaped + '"""' + if "'''" not in value and not value.endswith("'"): + return "'''\n" + value + "'''" + + return TomlIntegration._escape_toml_basic(value) + + @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("\\n")``'d before rendering, so the TOML + value preserves content without forcing a trailing newline. As a + result, multiline delimiters appear on their own line only when + the rendered value itself ends with a newline. + """ + toml_lines: list[str] = [] + + if description: + toml_lines.append( + f"description = {TomlIntegration._render_toml_string(description)}" + ) + toml_lines.append("") + + body = body.rstrip("\n") + toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(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, + project_root=project_root, + ) + _, body = self._split_frontmatter(processed) + toml_content = self._render_toml(description, body) + 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) + + + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options + ) + created.extend(event_files) + + return created + + +# --------------------------------------------------------------------------- +# YamlIntegration — YAML-format agents (Goose) +# --------------------------------------------------------------------------- + +# Characters a YAML literal block scalar cannot carry: C0 controls other +# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the +# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF. +# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1 +# treats it as a line break, which corrupts the block scalar's structure +# just the same, so all three are included. +_YAML_BLOCK_SCALAR_UNSAFE = re.compile( + r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]" +) + + +class YamlIntegration(IntegrationBase): + """Concrete base for integrations that use YAML recipe format. + + Mirrors ``TomlIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config``. Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + YAML recipe format (version, title, description, prompt block scalar). + """ + + def command_filename(self, template_name: str) -> str: + """YAML commands use ``.yaml`` extension.""" + return f"speckit.{template_name}.yaml" + + @staticmethod + def _extract_frontmatter(content: str) -> dict[str, Any]: + """Extract frontmatter as a dict from YAML frontmatter block.""" + + if not content.startswith("---"): + return {} + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return {} + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return {} + + frontmatter_text = "".join(lines[1:frontmatter_end]) + try: + fm = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return {} + + return fm if isinstance(fm, dict) else {} + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining body content.""" + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _human_title(identifier: str) -> str: + """Convert an identifier to a human-readable title. + + Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``, + and ``_`` with spaces before title-casing. + """ + text = identifier + if text.startswith("speckit."): + text = text[len("speckit.") :] + return text.replace(".", " ").replace("-", " ").replace("_", " ").title() + + + @classmethod + def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]: + """Build the base YAML header.""" + header = { + "version": "1.0.0", + "title": title, + "description": description, + "author": {"contact": "spec-kit"}, + "parameters": [ + { + "key": "args", + "input_type": "string", + "requirement": "optional", + "default": "", + "description": "User input passed to the command.", + } + ], + "extensions": [{"type": "builtin", "name": "developer"}], + "activities": ["Spec-Driven Development"], + } + return header + + @classmethod + def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: + """Render a YAML recipe file from title, description, and body. + + Produces a Goose-compatible recipe with a literal block scalar for + normal prompt content, or an escaped quoted scalar when control + characters require it. Uses ``yaml.safe_dump()`` for the header fields. + """ + header = cls._build_yaml_header(title, description) + + header_yaml = yaml.safe_dump( + header, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ).strip() + + # YAML forbids C0 control characters (except tab and newline) and + # DEL in every scalar form, and a bare CR acts as a line break + # inside a block scalar. A literal block scalar emits such bytes + # verbatim, producing a recipe the YAML parser rejects, so fall + # back to an escaped double-quoted scalar for those bodies. + if _YAML_BLOCK_SCALAR_UNSAFE.search(body): + prompt_yaml = yaml.safe_dump( + {"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize + ).strip() + lines = [ + header_yaml, + prompt_yaml, + "", + f"# Source: {source_id}", + ] + return "\n".join(lines) + "\n" + + # Indent the body for YAML block scalar. Use an explicit indentation + # indicator ("|2") rather than a bare "|": YAML infers a plain block + # scalar's indentation from its first non-empty line, so a body whose + # first line is itself indented (e.g. a markdown code block or a nested + # list item) would make the parser expect that deeper indent for the + # whole block and reject the later, less-indented lines. Pinning the + # indent to 2 keeps the recipe parseable whatever the body looks like. + indented = "\n".join(f" {line}" for line in body.split("\n")) + + lines = [ + header_yaml, + "prompt: |2", + indented, + "", + f"# Source: {source_id}", + ] + + return "\n".join(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") + fm = self._extract_frontmatter(raw) + description = fm.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + title = fm.get("title", "") or fm.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title: + title = self._human_title(src_file.stem) + + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + project_root=project_root, + ) + _, body = self._split_frontmatter(processed) + yaml_content = self._render_yaml( + title, description, body, f"templates/commands/{src_file.name}" + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + yaml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options + ) + created.extend(event_files) + + return created + + +# --------------------------------------------------------------------------- +# SkillsIntegration — skills-format agents (Codex, Kimi, Agy) +# --------------------------------------------------------------------------- + + +class SkillsIntegration(IntegrationBase): + """Concrete base for integrations that install commands as agent skills. + + Skills use the ``speckit-/SKILL.md`` directory layout following + the `agentskills.io `_ spec. + + Subclasses set ``key``, ``config``, ``registrar_config`` like any + integration. They may also + override ``options()`` to declare additional CLI flags (e.g. + ``--skills``, ``--migrate-legacy``). + + ``setup()`` processes each shared command template into a + ``speckit-/SKILL.md`` file with skills-oriented frontmatter. + """ + + invoke_separator = "-" + + def is_skills_mode( + self, + parsed_options: dict[str, Any] | None = None, + project_root: Path | None = None, + ) -> bool: + """Skills-native integrations scaffold skills unconditionally.""" + return True + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self._resolve_executable(), "-p", prompt] + self._apply_extra_args_env_var(args) + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def skills_dest(self, project_root: Path) -> Path: + """Return the absolute path to the skills output directory. + + Derived from ``config["folder"]`` and the configured + ``commands_subdir`` (defaults to ``"skills"``). + + Raises ``ValueError`` when ``config`` or ``folder`` is missing. + """ + if not self.config: + raise ValueError(f"{type(self).__name__}.config is not set.") + 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", "skills") + return project_root / folder / subdir + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the agent's native invocation for a hyphenated skill name.""" + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + prefix = "$" if is_dollar_skills_agent(self.key, True) else "/" + invocation = prefix + "speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + + @staticmethod + def _inject_hook_command_note( + content: str, invocation_prefix: str = "/" + ) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips individual instructions that already have the note immediately + above them. + """ + note = _HOOK_COMMAND_NOTE.rstrip("\n") + if invocation_prefix != "/": + note = note.replace( + "`/speckit-git-commit`", + f"`{invocation_prefix}speckit-git-commit`", + ) + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + note: + return m.group(0) + # ``eol`` is empty when the regex matched via ``$`` because the + # instruction was the final line of a file with no trailing + # newline. Default to ``\n`` so the note never collapses onto + # the same line as the instruction. + eol = m.group(3) or "\n" + return ( + indent + + note + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + def post_process_skill_content(self, content: str) -> str: + """Post-process a SKILL.md file's content after generation. + + Called by external skill generators (presets, extensions) to let + the integration inject agent-specific frontmatter or body + transformations. The base implementation injects shared skills + guidance for converting dotted hook command names to the agent-native + hyphenated command invocation (e.g. ``/speckit-git-commit`` or + ``$speckit-git-commit``). Subclasses may override -- see + ``ClaudeIntegration``. + """ + invocation_prefix = get_invocation_prefix(self.key, True) + return self._inject_hook_command_note(content, invocation_prefix) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install command templates as agent skills. + + Creates ``speckit-/SKILL.md`` for each shared command + template. Each SKILL.md has normalised frontmatter containing + ``name``, ``description``, ``compatibility``, and ``metadata``. + """ + + 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})" + ) + + skills_dir = self.skills_dest(project_root).resolve() + try: + skills_dir.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Skills destination {skills_dir} escapes " + f"project root {project_root_resolved}" + ) from exc + + 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") + + # Derive the skill name from the template stem + command_name = src_file.stem # e.g. "plan" + skill_name = f"speckit-{command_name.replace('.', '-')}" + + # Parse frontmatter for description. Locate the closing ``---`` on + # its own line rather than with ``raw.split("---", 2)`` — a bare + # substring split stops at the first ``---`` *anywhere*, including + # one inside a value such as ``description: Separate sections + # with ---``, which truncates the frontmatter and drops later keys. + # The block between the delimiters is parsed unstripped so trailing + # newlines in literal (``|``) block scalars survive. + frontmatter: dict[str, Any] = {} + if raw.startswith("---"): + fm_lines = raw.splitlines(keepends=True) + fm_close = next( + ( + i + for i in range(1, len(fm_lines)) + if fm_lines[i].rstrip() == "---" + ), + None, + ) + if fm_close is not None: + try: + fm = yaml.safe_load("".join(fm_lines[1:fm_close])) + if isinstance(fm, dict): + frontmatter = fm + except yaml.YAMLError: + pass + + # Process body through the standard template pipeline + processed_body = self.process_template( + raw, self.key, script_type, arg_placeholder, + project_root=project_root, + invoke_separator=self.invoke_separator, + ) + # Strip the processed frontmatter — we rebuild it for skills. + # Preserve leading whitespace in the body to match release ZIP + # output byte-for-byte (the template body starts with \n after + # the closing ---). Scan for the closing ``---`` on its own line + # rather than ``split("---", 2)`` so a ``---`` embedded in a value + # does not truncate the frontmatter and spill it into the body. + if processed_body.startswith("---"): + body_lines = processed_body.splitlines(keepends=True) + close_idx = next( + ( + i + for i in range(1, len(body_lines)) + if body_lines[i].rstrip() == "---" + ), + None, + ) + if close_idx is not None: + # Keep whatever trails the ``---`` marker on the closing + # line (normally just the newline) so the body stays + # byte-for-byte identical to ``split("---", 2)[2]``. The + # line-anchored check guarantees ``---`` sits at index 0. + processed_body = body_lines[close_idx][3:] + "".join( + body_lines[close_idx + 1 :] + ) + + # Select description — use the original template description + # to stay byte-for-byte identical with release ZIP output. + description = frontmatter.get("description", "") + if not description: + description = f"Spec Kit: {command_name} workflow" + + # Build SKILL.md with manually formatted frontmatter (stable + # double-quoted values). yaml_quote escapes newlines and control + # characters that a plain quoted f-string cannot carry. + skill_content = ( + f"---\n" + f"name: {yaml_quote(skill_name)}\n" + f"description: {yaml_quote(description)}\n" + f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"metadata:\n" + f" author: {yaml_quote('github-spec-kit')}\n" + f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" + f"---\n" + f"{processed_body}" + ) + + skill_content = self.post_process_skill_content(skill_content) + + # Write speckit-/SKILL.md + skill_dir = skills_dir / skill_name + skill_file = skill_dir / "SKILL.md" + dst = self.write_file_and_record( + skill_content, skill_file, project_root, manifest + ) + created.append(dst) + + + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options + ) + created.extend(event_files) + + return created diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 3718af9740..d6fac48258 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,956 +1,965 @@ -"""Tests for ClaudeIntegration.""" - -import json -import os -from pathlib import Path -from unittest.mock import patch - -import yaml - -from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration -from specify_cli.integrations.base import IntegrationBase, SkillsIntegration -from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS -from specify_cli.integrations.manifest import IntegrationManifest - - -class TestClaudeIntegration: - def test_registered(self): - assert "claude" in INTEGRATION_REGISTRY - assert get_integration("claude") is not None - - def test_is_base_integration(self): - assert isinstance(get_integration("claude"), IntegrationBase) - - def test_config_uses_skills(self): - integration = get_integration("claude") - assert integration.config["folder"] == ".claude/" - assert integration.config["commands_subdir"] == "skills" - - def test_registrar_config_uses_skill_layout(self): - integration = get_integration("claude") - assert integration.registrar_config["dir"] == ".claude/skills" - assert integration.registrar_config["format"] == "markdown" - assert integration.registrar_config["args"] == "$ARGUMENTS" - assert integration.registrar_config["extension"] == "/SKILL.md" - - def test_setup_creates_skill_files(self, tmp_path): - integration = get_integration("claude") - manifest = IntegrationManifest("claude", tmp_path) - created = integration.setup(tmp_path, manifest, script_type="sh") - +"""Tests for ClaudeIntegration.""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase, SkillsIntegration +from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestClaudeIntegration: + def test_registered(self): + assert "claude" in INTEGRATION_REGISTRY + assert get_integration("claude") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("claude"), IntegrationBase) + + def test_config_uses_skills(self): + integration = get_integration("claude") + assert integration.config["folder"] == ".claude/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("claude") + assert integration.registrar_config["dir"] == ".claude/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "$ARGUMENTS" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + skill_files = [path for path in created if path.name == "SKILL.md"] assert skill_files - skills_dir = tmp_path / ".claude" / "skills" - assert skills_dir.is_dir() - - plan_skill = skills_dir / "speckit-plan" / "SKILL.md" - assert plan_skill.exists() - - content = plan_skill.read_text(encoding="utf-8") - assert "{SCRIPT}" not in content - assert "{ARGS}" not in content - assert "__AGENT__" not in content - assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" - assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." - - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - assert parsed["name"] == "speckit-plan" - assert parsed["user-invocable"] is True - assert parsed["disable-model-invocation"] is False - assert parsed["metadata"]["source"] == "templates/commands/plan.md" - - def test_render_skill_unicode(self): - """Test rendering a skill preserves non-ASCII characters.""" - integration = get_integration("claude") - rendered = integration._render_skill( - "constitution", - {"description": "Prüfe Konformität der Implementierung"}, - "Body", - ) - assert "Prüfe Konformität" in rendered - - def test_setup_does_not_write_context_section(self, tmp_path): - """The CLI no longer manages the agent context file — that is owned by - the opt-in agent-context extension. Setup must not create or touch it.""" - integration = get_integration("claude") - manifest = IntegrationManifest("claude", tmp_path) - integration.setup(tmp_path, manifest, script_type="sh") - - for path in tmp_path.rglob("*"): - if path.is_file(): - text = path.read_text(encoding="utf-8", errors="ignore") - assert "" not in text - - def test_teardown_does_not_touch_existing_context_file(self, tmp_path): - """A user-authored context file is left intact on teardown.""" - integration = get_integration("claude") - ctx_path = tmp_path / "CLAUDE.md" - original = "# CLAUDE.md\n\nUser content.\n" - ctx_path.write_text(original, encoding="utf-8") - - manifest = IntegrationManifest("claude", tmp_path) - integration.setup(tmp_path, manifest, script_type="sh") - integration.teardown(tmp_path, manifest) - - assert ctx_path.read_text(encoding="utf-8") == original - - def test_integration_flag_creates_skill_files_cli(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "claude-promote" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - runner = CliRunner() - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - "claude", - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0, result.output - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert not (project / ".claude" / "commands").exists() - - init_options = json.loads( - (project / ".specify" / "init-options.json").read_text(encoding="utf-8") - ) - assert init_options["ai"] == "claude" - assert init_options["ai_skills"] is True - assert init_options["integration"] == "claude" - - def test_integration_flag_creates_skill_files(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "claude-integration" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - runner = CliRunner() - result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - "claude", - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0, result.output - assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() - assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() - - def test_interactive_claude_selection_uses_integration_path(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "claude-interactive" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - runner = CliRunner() - with ( - patch("specify_cli.commands.init._stdin_is_interactive", return_value=True), - patch("specify_cli.commands.init.select_with_arrows", return_value="claude"), - ): - result = runner.invoke( - app, - [ - "init", - "--here", - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0, result.output - assert (project / ".specify" / "integration.json").exists() - assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() - - skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - assert skill_file.exists() - skill_content = skill_file.read_text(encoding="utf-8") - assert "user-invocable: true" in skill_content - assert "disable-model-invocation: false" in skill_content - - init_options = json.loads( - (project / ".specify" / "init-options.json").read_text(encoding="utf-8") - ) - assert init_options["ai"] == "claude" - assert init_options["ai_skills"] is True - assert init_options["integration"] == "claude" - - def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): - """Claude init should succeed even without install_skills.""" - from typer.testing import CliRunner - from specify_cli import app - - runner = CliRunner() - target = tmp_path / "fail-proj" - - result = runner.invoke( - app, - ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"], - ) - - assert result.exit_code == 0 - assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() - - def test_claude_hooks_render_skill_invocation(self, tmp_path): - from specify_cli.extensions import HookExecutor - - project = tmp_path / "claude-hooks" - project.mkdir() - init_options = project / ".specify" / "init-options.json" - init_options.parent.mkdir(parents=True, exist_ok=True) - init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True})) - - hook_executor = HookExecutor(project) - message = hook_executor.format_hook_message( - "before_plan", - [ - { - "extension": "test-ext", - "command": "speckit.plan", - "optional": False, - } - ], - ) - - assert "Executing: `/speckit-plan`" in message - assert "EXECUTE_COMMAND: speckit.plan" in message - assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message - - def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): - from specify_cli import save_init_options - from specify_cli.presets import PresetManager - - project = tmp_path / "claude-preset-skill" - project.mkdir() - save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"}) - - skills_dir = project / ".claude" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - - preset_dir = tmp_path / "claude-skill-command" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.research.md").write_text( - "---\n" - "description: Research workflow\n" - "---\n\n" - "preset:claude-skill-command\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "claude-skill-command", - "name": "Claude Skill Command", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.research", - "file": "commands/speckit.research.md", - } - ] - }, + manifest.save() + manifest_data = json.loads(manifest.manifest_path.read_text(encoding="utf-8")) + recorded_paths = set(manifest_data["files"]) + expected_paths = { + path.relative_to(tmp_path).as_posix() for path in skill_files } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-research" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text(encoding="utf-8") - assert "preset:claude-skill-command" in content - assert "name: speckit-research" in content - assert "user-invocable: true" in content - assert "disable-model-invocation: false" in content - - metadata = manager.registry.get("claude-skill-command") - assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", []) - - -class TestClaudeArgumentHints: - """Verify that argument-hint frontmatter is injected for Claude skills.""" - - def test_converge_has_no_argument_hint(self): - """Converge should not advertise unsupported feature-name arguments.""" - assert "converge" not in ARGUMENT_HINTS - - def test_all_skills_have_hints(self, tmp_path): - """Every skill with a configured hint must contain an argument-hint line.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - assert len(skill_files) > 0 - for f in skill_files: - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - content = f.read_text(encoding="utf-8") - if stem in ARGUMENT_HINTS: - assert "argument-hint:" in content, ( - f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" - ) - else: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - - def test_hints_match_expected_values(self, tmp_path): - """Each skill's argument-hint must match the expected text.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - for f in skill_files: - # Extract stem: speckit-plan -> plan - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - expected_hint = ARGUMENT_HINTS.get(stem) - content = f.read_text(encoding="utf-8") - if expected_hint is None: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - else: - assert f'argument-hint: "{expected_hint}"' in content, ( - f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" - ) - - def test_hint_is_inside_frontmatter(self, tmp_path): - """argument-hint must appear between the --- delimiters, not in the body.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - for f in skill_files: - content = f.read_text(encoding="utf-8") - parts = content.split("---", 2) - assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" - frontmatter = parts[1] - body = parts[2] - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - if stem in ARGUMENT_HINTS: - assert "argument-hint:" in frontmatter, ( - f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" - ) - assert "argument-hint:" not in body, ( - f"{f.parent.name}/SKILL.md: argument-hint leaked into body" - ) - else: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - - def test_hint_appears_after_description(self, tmp_path): - """argument-hint must immediately follow the description line.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - for f in skill_files: - content = f.read_text(encoding="utf-8") - lines = content.splitlines() - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - if stem not in ARGUMENT_HINTS: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - continue - found_description = False - for idx, line in enumerate(lines): - if line.startswith("description:"): - found_description = True - assert idx + 1 < len(lines), ( - f"{f.parent.name}/SKILL.md: description is last line" - ) - assert lines[idx + 1].startswith("argument-hint:"), ( - f"{f.parent.name}/SKILL.md: argument-hint does not follow description" - ) - break - assert found_description, ( - f"{f.parent.name}/SKILL.md: no description: line found in output" - ) - - def test_inject_argument_hint_only_in_frontmatter(self): - """inject_argument_hint must not modify description: lines in the body.""" - from specify_cli.integrations.claude import ClaudeIntegration - - content = ( - "---\n" - "description: My command\n" - "---\n" - "\n" - "description: this is body text\n" - ) - result = ClaudeIntegration.inject_argument_hint(content, "Test hint") - lines = result.splitlines() - hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) - assert hint_count == 1, ( - f"Expected exactly 1 argument-hint line, found {hint_count}" - ) - - def test_inject_argument_hint_skips_if_already_present(self): - """inject_argument_hint must not duplicate if argument-hint already exists.""" - from specify_cli.integrations.claude import ClaudeIntegration - - content = ( - "---\n" - "description: My command\n" - 'argument-hint: "Existing hint"\n' - "---\n" - "\n" - "Body text\n" - ) - result = ClaudeIntegration.inject_argument_hint(content, "New hint") - assert result == content, "Content should be unchanged when hint already exists" - lines = result.splitlines() - hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) - assert hint_count == 1 - - def test_inject_argument_hint_survives_folded_description(self): - """A long description folded across lines must not corrupt the YAML (#4044). - - A description long enough for the YAML dumper to fold it into a - multi-line plain scalar previously had ``argument-hint:`` spliced - into the *middle* of that scalar, producing invalid YAML. - """ - from specify_cli.integrations.claude import ClaudeIntegration - - frontmatter = { - "name": "speckit-specify", - "description": ( - "Create or update the feature specification from a natural " - "language feature description. Also accepts an issue URL " - "resolved via gh CLI (demo customization)." - ), - "compatibility": "Requires spec-kit project structure with .specify/ directory", - } - frontmatter_text = yaml.safe_dump( - frontmatter, sort_keys=False, allow_unicode=True - ).strip() - content = f"---\n{frontmatter_text}\n---\n\nBody text\n" - assert "\n " in content, "fixture description must actually fold across lines" - - result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + assert expected_paths <= recorded_paths + assert ".claude/skills/speckit-converge/SKILL.md" in recorded_paths - parsed = yaml.safe_load(result.split("---")[1]) - assert parsed["argument-hint"] == "Describe the feature" - assert parsed["description"] == frontmatter["description"] - - def test_inject_argument_hint_survives_quoted_folded_description(self): - """A folded description forced into quotes must not absorb the hint (#4044).""" - from specify_cli.integrations.claude import ClaudeIntegration - - frontmatter = { - "name": "speckit-specify", - "description": ( - "Create or update the feature specification from a natural " - "language feature description. Also accepts a GitHub " - "issue/PR URL or #N reference resolved via gh CLI (demo)." - ), - "compatibility": "Requires spec-kit project structure with .specify/ directory", - } - frontmatter_text = yaml.safe_dump( - frontmatter, sort_keys=False, allow_unicode=True - ).strip() - content = f"---\n{frontmatter_text}\n---\n\nBody text\n" - assert "\n " in content, "fixture description must actually fold across lines" - - result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") - - parsed = yaml.safe_load(result.split("---")[1]) - assert parsed["argument-hint"] == "Describe the feature" - assert parsed["description"] == frontmatter["description"] - - def test_inject_argument_hint_survives_multi_paragraph_description(self): - """A description with an embedded blank line must not absorb the hint. - - PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as - unindented blank lines, not indented ones, so a fix that only skips - indented continuation lines still fails on this case. - """ - from specify_cli.integrations.claude import ClaudeIntegration - - frontmatter = { - "name": "speckit-specify", - "description": ( - "First paragraph of a fairly long description that will " - "need to wrap across multiple lines when dumped by PyYAML." - "\n\n" - "Second paragraph continues the description after a blank " - "line separator to force embedded newlines in the scalar." - ), - "compatibility": "Requires spec-kit project structure with .specify/ directory", - } - frontmatter_text = yaml.safe_dump( - frontmatter, sort_keys=False, allow_unicode=True - ).strip() - content = f"---\n{frontmatter_text}\n---\n\nBody text\n" - assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line" - - result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") - - parsed = yaml.safe_load(result.split("---")[1]) - assert parsed["argument-hint"] == "Describe the feature" - assert parsed["description"] == frontmatter["description"] - - -class TestClaudeDisableModelInvocation: - """Verify disable-model-invocation is false for Claude skills.""" - - def test_setup_sets_disable_model_invocation_false(self, tmp_path): - """Generated SKILL.md files must have disable-model-invocation: false.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - assert len(skill_files) > 0 - for f in skill_files: - content = f.read_text(encoding="utf-8") - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - assert parsed["disable-model-invocation"] is False, ( - f"{f.parent.name}: expected disable-model-invocation: false" - ) - - def test_disable_model_invocation_not_true(self, tmp_path): - """No Claude skill should have disable-model-invocation: true.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - for f in created: - if f.name != "SKILL.md": - continue - content = f.read_text(encoding="utf-8") - assert "disable-model-invocation: true" not in content, ( - f"{f.parent.name}: must not have disable-model-invocation: true" - ) - - def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): - """Non-Claude skill agents should not get disable-model-invocation.""" - from specify_cli.agents import CommandRegistrar - - fm = CommandRegistrar.build_skill_frontmatter( - "codex", "speckit-plan", "desc", "templates/commands/plan.md" - ) - assert "disable-model-invocation" not in fm - assert "user-invocable" not in fm - - def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path): - """SkillsIntegration agents without an override preserve non-hook content.""" - # ``agy`` is a plain SkillsIntegration with no post-process override, - # so it stands in for the base-class default behavior. - agy = get_integration("agy") - if agy is None: - return # agy not registered in this build - content = "---\nname: test\n---\nBody" - assert agy.post_process_skill_content(content) == content - - -class TestClaudeForkContext: - """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" - - def test_no_commands_fork_by_default(self): - """FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork. - - ``analyze`` was removed (#3185) because its verbose report defeated the - purpose of forking and compounded context overhead across repeated runs. - """ - assert FORK_CONTEXT_COMMANDS == {} - - def test_analyze_skill_does_not_fork(self, tmp_path): - """speckit-analyze must run in the main session, not a forked subagent (#3185).""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - i.setup(tmp_path, m, script_type="sh") - analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" - assert analyze_skill.exists() - content = analyze_skill.read_text(encoding="utf-8") - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - assert "context" not in parsed - assert "agent" not in parsed - - def test_no_skills_fork(self, tmp_path): - """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") - skill_files = [f for f in created if f.name == "SKILL.md"] - for f in skill_files: - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - if stem in FORK_CONTEXT_COMMANDS: - continue - content = f.read_text(encoding="utf-8") - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - assert "context" not in parsed, ( - f"{f.parent.name}: must not have context frontmatter" - ) - assert "agent" not in parsed, ( - f"{f.parent.name}: must not have agent frontmatter" - ) - - def test_post_process_no_fork_for_skills(self): - """With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent.""" - i = get_integration("claude") - for name in ("speckit-analyze", "speckit-plan"): - content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n' - result = i.post_process_skill_content(content) - parsed = yaml.safe_load(result.split("---", 2)[1]) - assert "context" not in parsed - assert "agent" not in parsed - - def test_fork_mechanism_injects_when_configured(self, monkeypatch): - """The injection mechanism still works for any command added to - FORK_CONTEXT_COMMANDS, even though none ships enabled by default.""" - import specify_cli.integrations.claude as claude_mod - - monkeypatch.setitem( - claude_mod.FORK_CONTEXT_COMMANDS, - "analyze", - {"context": "fork", "agent": "general-purpose"}, - ) - i = get_integration("claude") - content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' - result = i.post_process_skill_content(content) - parts = result.split("---", 2) - parsed = yaml.safe_load(parts[1]) - assert parsed.get("context") == "fork" - assert parsed.get("agent") == "general-purpose" - # Flags must land in the frontmatter, not the body. - assert "context: fork" in parts[1] - assert "context: fork" not in parts[2] - # Re-running must not duplicate the injected keys. - twice = i.post_process_skill_content(result) - assert result == twice - assert twice.count("context: fork") == 1 - assert twice.count("agent: general-purpose") == 1 - - -class TestClaudeHookCommandNote: - """Verify dot-to-hyphen normalization note is injected in hook sections.""" - - def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): - """Skills that have hook sections should get the normalization note.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - i.setup(tmp_path, m, script_type="sh") - specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" - assert specify_skill.exists() - content = specify_skill.read_text(encoding="utf-8") - # specify.md has hook sections - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) - - def test_hook_note_not_in_skills_without_hooks(self, tmp_path): - """Skills without hook sections should not get the note.""" - content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" - result = SkillsIntegration._inject_hook_command_note(content) - assert "replace dots" not in result - - def test_hook_note_idempotent(self, tmp_path): - """Injecting the note twice should not duplicate it.""" - content = ( - "---\nname: test\n---\n\n" - "- For each executable hook, output the following based on its flag:\n" - ) - once = SkillsIntegration._inject_hook_command_note(content) - twice = SkillsIntegration._inject_hook_command_note(once) - assert once == twice, "Hook note injection should be idempotent" - - def test_hook_note_fills_missing_repeated_instructions(self, tmp_path): - """Already-noted hook sections should not suppress later sections.""" - from specify_cli.integrations.base import _HOOK_COMMAND_NOTE - - content = ( - "---\nname: test\n---\n\n" - f"{_HOOK_COMMAND_NOTE}" - "- For each executable hook, output the following based on its flag:\n" - "\n" - " - For each executable hook, output the following based on its flag:\n" - ) - result = SkillsIntegration._inject_hook_command_note(content) - assert result.count("replace dots (`.`) with hyphens") == 2 - - def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path): - """Unrelated text should not trip the hook-note idempotence guard.""" - content = ( - "---\nname: test\n---\n\n" - "This paragraph says replace dots in a different context.\n" - "- For each executable hook, output the following based on its flag:\n" - ) - result = SkillsIntegration._inject_hook_command_note(content) - assert "This paragraph says replace dots in a different context." in result - assert result.count("replace dots (`.`) with hyphens") == 1 - - def test_hook_note_preserves_indentation(self, tmp_path): - """The injected note should match the indentation of the target line.""" - content = ( - "---\nname: test\n---\n\n" - " - For each executable hook, output the following\n" - ) - result = SkillsIntegration._inject_hook_command_note(content) - lines = result.splitlines() - note_line = [line for line in lines if "replace dots" in line][0] - assert note_line.startswith(" "), "Note should preserve indentation" - - def test_post_process_injects_all_claude_flags(self): - """post_process_skill_content should inject all Claude-specific fields.""" - i = get_integration("claude") - content = ( - "---\nname: test\ndescription: test\n---\n\n" - "- For each executable hook, output the following\n" - ) - result = i.post_process_skill_content(content) - assert "user-invocable: true" in result - assert "disable-model-invocation: false" in result - assert "replace dots" in result - - -class TestSpeckitManifestRecordsSkippedFiles: - """Regression test for issue #2107. - - ``install_shared_infra`` must record every shared-infrastructure file - under ``.specify/`` in ``speckit.manifest.json``, including files that - were *skipped* because they already existed on disk and ``force=False``. - - Before the fix, the skip branches in the scripts and templates loops - appended to ``skipped_files`` without calling ``manifest.record_existing``. - So when ``install_shared_infra`` ran with a fresh (or lost) manifest - against an already-populated ``.specify/`` tree, every file went down the - skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and - ``manifest.save()`` wrote an empty ``files`` field — leaving the - integration believing nothing was installed. - - Reproduction (without the fix) using ``install_shared_infra`` directly: - - install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files - (p / ".specify/integrations/speckit.manifest.json").unlink() - install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files - # ^^ BUG: empty - """ - - def _read_manifest_files(self, project_path: Path) -> dict: - manifest_path = ( - project_path / ".specify" / "integrations" / "speckit.manifest.json" - ) - assert manifest_path.exists(), ( - f"speckit.manifest.json not written at {manifest_path}" - ) - data = json.loads(manifest_path.read_text(encoding="utf-8")) - # ``IntegrationManifest.save`` serialises a ``files`` dict — assert - # the schema explicitly so a regression to a different key (e.g. - # the internal ``_files`` attribute name) fails loudly instead of - # being masked by a silent fallback. - assert isinstance(data, dict), ( - f"manifest root is not a dict, got {type(data).__name__}" - ) - assert "files" in data, ( - f"manifest missing 'files' key, got keys: {sorted(data.keys())}" - ) - files = data["files"] - assert isinstance(files, dict), ( - f"manifest 'files' is not a dict, got {type(files).__name__}" - ) - return files - - def test_install_shared_infra_records_skipped_files(self, tmp_path): - """With ``force=False`` and ``.specify/`` already populated, the - manifest must still record every file — the skip branches are not - allowed to drop files from the manifest.""" - from rich.console import Console - from specify_cli.shared_infra import install_shared_infra - - # Resolve the project's own packaged sources by walking up from this - # test file to the repo root (which contains ``scripts/`` and - # ``templates/`` that ``shared_scripts_source`` looks for). - repo_root = Path(__file__).resolve().parents[2] - console = Console(quiet=True) - - # First run — fresh project, manifest gets populated normally. - install_shared_infra( - tmp_path, - "sh", - version="0.0.0", - core_pack=None, - repo_root=repo_root, - console=console, - force=False, - ) - first_files = self._read_manifest_files(tmp_path) - assert first_files, "first install produced an empty manifest" - - # Simulate a lost manifest while ``.specify/`` is still on disk - # (e.g. the manifest was deleted, corrupted, or the layout was - # extracted out-of-band). - manifest_path = ( - tmp_path / ".specify" / "integrations" / "speckit.manifest.json" - ) - manifest_path.unlink() - - # Second run — every file already exists, so every iteration takes - # the skip branch. With the fix, those files are still recorded. - install_shared_infra( - tmp_path, - "sh", - version="0.0.0", - core_pack=None, - repo_root=repo_root, - console=console, - force=False, - ) - second_files = self._read_manifest_files(tmp_path) - assert second_files, ( - "speckit.manifest.json files dict is empty after install with " - "skipped files (issue #2107) — every file went down the skip " - "branch but none were recorded" - ) - - # The recovered manifest must cover everything the first run tracked. - missing = set(first_files) - set(second_files) - assert not missing, ( - f"these files were tracked on the first install but missing after " - f"the skipped-files re-install: {sorted(missing)[:5]}" - ) - - def test_install_shared_infra_handles_directory_at_script_destination( - self, tmp_path - ): - """A non-file (directory) at a script's destination must NOT crash - ``install_shared_infra`` and must NOT be recorded in the manifest — - the path still appears in the user-visible skipped-paths warning. - """ - from io import StringIO - from rich.console import Console - from specify_cli.shared_infra import install_shared_infra - - repo_root = Path(__file__).resolve().parents[2] - output = StringIO() - console = Console(file=output, force_terminal=False, width=200) - - # Pre-create the .specify/scripts/bash tree, then plant a directory - # where a script file is expected so the skip branch hits a - # non-regular-file path. - bash_dir = tmp_path / ".specify" / "scripts" / "bash" - bash_dir.mkdir(parents=True) - (bash_dir / "common.sh").mkdir() # collision: dir where file expected - - # Must not crash. - install_shared_infra( - tmp_path, - "sh", - version="0.0.0", - core_pack=None, - repo_root=repo_root, - console=console, - force=False, - ) - - files = self._read_manifest_files(tmp_path) - assert ".specify/scripts/bash/common.sh" not in files, ( - "directory at script dst must not be recorded in the manifest" - ) - text = output.getvalue() - assert "common.sh" in text, ( - "directory-at-script-dst path must surface in the skipped warning" - ) - - def test_install_shared_infra_handles_directory_at_template_destination( - self, tmp_path - ): - """Symmetric coverage for the templates loop: a directory at a - template's destination must NOT crash install nor be recorded.""" - from io import StringIO - from rich.console import Console - from specify_cli.shared_infra import install_shared_infra - - repo_root = Path(__file__).resolve().parents[2] - output = StringIO() - console = Console(file=output, force_terminal=False, width=200) - - templates_dir = tmp_path / ".specify" / "templates" - templates_dir.mkdir(parents=True) - - src_templates = repo_root / "templates" - real_template = next( - ( - p.name - for p in src_templates.iterdir() - if p.is_file() - and not p.name.startswith(".") - and p.name != "vscode-settings.json" - ), - None, - ) - assert real_template, ( - "no real template found in repo to collide against" - ) - (templates_dir / real_template).mkdir() # collision - - install_shared_infra( - tmp_path, - "sh", - version="0.0.0", - core_pack=None, - repo_root=repo_root, - console=console, - force=False, - ) - - files = self._read_manifest_files(tmp_path) - template_rel = f".specify/templates/{real_template}" - assert template_rel not in files, ( - "directory at template dst must not be recorded in manifest" - ) - text = output.getvalue() - assert real_template in text, ( - "directory-at-template-dst path must surface in the skipped warning" - ) + skills_dir = tmp_path / ".claude" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" + assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True + assert parsed["disable-model-invocation"] is False + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_render_skill_unicode(self): + """Test rendering a skill preserves non-ASCII characters.""" + integration = get_integration("claude") + rendered = integration._render_skill( + "constitution", + {"description": "Prüfe Konformität der Implementierung"}, + "Body", + ) + assert "Prüfe Konformität" in rendered + + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" + integration = get_integration("claude") + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") + + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) + + assert ctx_path.read_text(encoding="utf-8") == original + + def test_integration_flag_creates_skill_files_cli(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-promote" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert not (project / ".claude" / "commands").exists() + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_integration_flag_creates_skill_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-integration" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + def test_interactive_claude_selection_uses_integration_path(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-interactive" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + with ( + patch("specify_cli.commands.init._stdin_is_interactive", return_value=True), + patch("specify_cli.commands.init.select_with_arrows", return_value="claude"), + ): + result = runner.invoke( + app, + [ + "init", + "--here", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "integration.json").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert skill_file.exists() + skill_content = skill_file.read_text(encoding="utf-8") + assert "user-invocable: true" in skill_content + assert "disable-model-invocation: false" in skill_content + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): + """Claude init should succeed even without install_skills.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "fail-proj" + + result = runner.invoke( + app, + ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"], + ) + + assert result.exit_code == 0 + assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + + def test_claude_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "claude-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + + assert "Executing: `/speckit-plan`" in message + assert "EXECUTE_COMMAND: speckit.plan" in message + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): + from specify_cli import save_init_options + from specify_cli.presets import PresetManager + + project = tmp_path / "claude-preset-skill" + project.mkdir() + save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"}) + + skills_dir = project / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = tmp_path / "claude-skill-command" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Research workflow\n" + "---\n\n" + "preset:claude-skill-command\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "claude-skill-command", + "name": "Claude Skill Command", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "preset:claude-skill-command" in content + assert "name: speckit-research" in content + assert "user-invocable: true" in content + assert "disable-model-invocation: false" in content + + metadata = manager.registry.get("claude-skill-command") + assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", []) + + +class TestClaudeArgumentHints: + """Verify that argument-hint frontmatter is injected for Claude skills.""" + + def test_converge_has_no_argument_hint(self): + """Converge should not advertise unsupported feature-name arguments.""" + assert "converge" not in ARGUMENT_HINTS + + def test_all_skills_have_hints(self, tmp_path): + """Every skill with a configured hint must contain an argument-hint line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + content = f.read_text(encoding="utf-8") + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in content, ( + f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + + def test_hints_match_expected_values(self, tmp_path): + """Each skill's argument-hint must match the expected text.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + # Extract stem: speckit-plan -> plan + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + expected_hint = ARGUMENT_HINTS.get(stem) + content = f.read_text(encoding="utf-8") + if expected_hint is None: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + else: + assert f'argument-hint: "{expected_hint}"' in content, ( + f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" + ) + + def test_hint_is_inside_frontmatter(self, tmp_path): + """argument-hint must appear between the --- delimiters, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" + frontmatter = parts[1] + body = parts[2] + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in frontmatter, ( + f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.parent.name}/SKILL.md: argument-hint leaked into body" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + + def test_hint_appears_after_description(self, tmp_path): + """argument-hint must immediately follow the description line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + lines = content.splitlines() + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem not in ARGUMENT_HINTS: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + continue + found_description = False + for idx, line in enumerate(lines): + if line.startswith("description:"): + found_description = True + assert idx + 1 < len(lines), ( + f"{f.parent.name}/SKILL.md: description is last line" + ) + assert lines[idx + 1].startswith("argument-hint:"), ( + f"{f.parent.name}/SKILL.md: argument-hint does not follow description" + ) + break + assert found_description, ( + f"{f.parent.name}/SKILL.md: no description: line found in output" + ) + + def test_inject_argument_hint_only_in_frontmatter(self): + """inject_argument_hint must not modify description: lines in the body.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + "---\n" + "\n" + "description: this is body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "Test hint") + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1, ( + f"Expected exactly 1 argument-hint line, found {hint_count}" + ) + + def test_inject_argument_hint_skips_if_already_present(self): + """inject_argument_hint must not duplicate if argument-hint already exists.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + 'argument-hint: "Existing hint"\n' + "---\n" + "\n" + "Body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "New hint") + assert result == content, "Content should be unchanged when hint already exists" + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1 + + def test_inject_argument_hint_survives_folded_description(self): + """A long description folded across lines must not corrupt the YAML (#4044). + + A description long enough for the YAML dumper to fold it into a + multi-line plain scalar previously had ``argument-hint:`` spliced + into the *middle* of that scalar, producing invalid YAML. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts an issue URL " + "resolved via gh CLI (demo customization)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_quoted_folded_description(self): + """A folded description forced into quotes must not absorb the hint (#4044).""" + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "Create or update the feature specification from a natural " + "language feature description. Also accepts a GitHub " + "issue/PR URL or #N reference resolved via gh CLI (demo)." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n " in content, "fixture description must actually fold across lines" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + def test_inject_argument_hint_survives_multi_paragraph_description(self): + """A description with an embedded blank line must not absorb the hint. + + PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as + unindented blank lines, not indented ones, so a fix that only skips + indented continuation lines still fails on this case. + """ + from specify_cli.integrations.claude import ClaudeIntegration + + frontmatter = { + "name": "speckit-specify", + "description": ( + "First paragraph of a fairly long description that will " + "need to wrap across multiple lines when dumped by PyYAML." + "\n\n" + "Second paragraph continues the description after a blank " + "line separator to force embedded newlines in the scalar." + ), + "compatibility": "Requires spec-kit project structure with .specify/ directory", + } + frontmatter_text = yaml.safe_dump( + frontmatter, sort_keys=False, allow_unicode=True + ).strip() + content = f"---\n{frontmatter_text}\n---\n\nBody text\n" + assert "\n\n" in frontmatter_text, "fixture must produce a blank continuation line" + + result = ClaudeIntegration.inject_argument_hint(content, "Describe the feature") + + parsed = yaml.safe_load(result.split("---")[1]) + assert parsed["argument-hint"] == "Describe the feature" + assert parsed["description"] == frontmatter["description"] + + +class TestClaudeDisableModelInvocation: + """Verify disable-model-invocation is false for Claude skills.""" + + def test_setup_sets_disable_model_invocation_false(self, tmp_path): + """Generated SKILL.md files must have disable-model-invocation: false.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["disable-model-invocation"] is False, ( + f"{f.parent.name}: expected disable-model-invocation: false" + ) + + def test_disable_model_invocation_not_true(self, tmp_path): + """No Claude skill should have disable-model-invocation: true.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + for f in created: + if f.name != "SKILL.md": + continue + content = f.read_text(encoding="utf-8") + assert "disable-model-invocation: true" not in content, ( + f"{f.parent.name}: must not have disable-model-invocation: true" + ) + + def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): + """Non-Claude skill agents should not get disable-model-invocation.""" + from specify_cli.agents import CommandRegistrar + + fm = CommandRegistrar.build_skill_frontmatter( + "codex", "speckit-plan", "desc", "templates/commands/plan.md" + ) + assert "disable-model-invocation" not in fm + assert "user-invocable" not in fm + + def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path): + """SkillsIntegration agents without an override preserve non-hook content.""" + # ``agy`` is a plain SkillsIntegration with no post-process override, + # so it stands in for the base-class default behavior. + agy = get_integration("agy") + if agy is None: + return # agy not registered in this build + content = "---\nname: test\n---\nBody" + assert agy.post_process_skill_content(content) == content + + +class TestClaudeForkContext: + """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" + + def test_no_commands_fork_by_default(self): + """FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork. + + ``analyze`` was removed (#3185) because its verbose report defeated the + purpose of forking and compounded context overhead across repeated runs. + """ + assert FORK_CONTEXT_COMMANDS == {} + + def test_analyze_skill_does_not_fork(self, tmp_path): + """speckit-analyze must run in the main session, not a forked subagent (#3185).""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" + assert analyze_skill.exists() + content = analyze_skill.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_no_skills_fork(self, tmp_path): + """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in FORK_CONTEXT_COMMANDS: + continue + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert "context" not in parsed, ( + f"{f.parent.name}: must not have context frontmatter" + ) + assert "agent" not in parsed, ( + f"{f.parent.name}: must not have agent frontmatter" + ) + + def test_post_process_no_fork_for_skills(self): + """With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent.""" + i = get_integration("claude") + for name in ("speckit-analyze", "speckit-plan"): + content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_fork_mechanism_injects_when_configured(self, monkeypatch): + """The injection mechanism still works for any command added to + FORK_CONTEXT_COMMANDS, even though none ships enabled by default.""" + import specify_cli.integrations.claude as claude_mod + + monkeypatch.setitem( + claude_mod.FORK_CONTEXT_COMMANDS, + "analyze", + {"context": "fork", "agent": "general-purpose"}, + ) + i = get_integration("claude") + content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parts = result.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("context") == "fork" + assert parsed.get("agent") == "general-purpose" + # Flags must land in the frontmatter, not the body. + assert "context: fork" in parts[1] + assert "context: fork" not in parts[2] + # Re-running must not duplicate the injected keys. + twice = i.post_process_skill_content(result) + assert result == twice + assert twice.count("context: fork") == 1 + assert twice.count("agent: general-purpose") == 1 + + +class TestClaudeHookCommandNote: + """Verify dot-to-hyphen normalization note is injected in hook sections.""" + + def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): + """Skills that have hook sections should get the normalization note.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + i.setup(tmp_path, m, script_type="sh") + specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" + assert specify_skill.exists() + content = specify_skill.read_text(encoding="utf-8") + # specify.md has hook sections + assert "replace dots" in content, ( + "speckit-specify should have dot-to-hyphen hook note" + ) + + def test_hook_note_not_in_skills_without_hooks(self, tmp_path): + """Skills without hook sections should not get the note.""" + content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" + result = SkillsIntegration._inject_hook_command_note(content) + assert "replace dots" not in result + + def test_hook_note_idempotent(self, tmp_path): + """Injecting the note twice should not duplicate it.""" + content = ( + "---\nname: test\n---\n\n" + "- For each executable hook, output the following based on its flag:\n" + ) + once = SkillsIntegration._inject_hook_command_note(content) + twice = SkillsIntegration._inject_hook_command_note(once) + assert once == twice, "Hook note injection should be idempotent" + + def test_hook_note_fills_missing_repeated_instructions(self, tmp_path): + """Already-noted hook sections should not suppress later sections.""" + from specify_cli.integrations.base import _HOOK_COMMAND_NOTE + + content = ( + "---\nname: test\n---\n\n" + f"{_HOOK_COMMAND_NOTE}" + "- For each executable hook, output the following based on its flag:\n" + "\n" + " - For each executable hook, output the following based on its flag:\n" + ) + result = SkillsIntegration._inject_hook_command_note(content) + assert result.count("replace dots (`.`) with hyphens") == 2 + + def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path): + """Unrelated text should not trip the hook-note idempotence guard.""" + content = ( + "---\nname: test\n---\n\n" + "This paragraph says replace dots in a different context.\n" + "- For each executable hook, output the following based on its flag:\n" + ) + result = SkillsIntegration._inject_hook_command_note(content) + assert "This paragraph says replace dots in a different context." in result + assert result.count("replace dots (`.`) with hyphens") == 1 + + def test_hook_note_preserves_indentation(self, tmp_path): + """The injected note should match the indentation of the target line.""" + content = ( + "---\nname: test\n---\n\n" + " - For each executable hook, output the following\n" + ) + result = SkillsIntegration._inject_hook_command_note(content) + lines = result.splitlines() + note_line = [line for line in lines if "replace dots" in line][0] + assert note_line.startswith(" "), "Note should preserve indentation" + + def test_post_process_injects_all_claude_flags(self): + """post_process_skill_content should inject all Claude-specific fields.""" + i = get_integration("claude") + content = ( + "---\nname: test\ndescription: test\n---\n\n" + "- For each executable hook, output the following\n" + ) + result = i.post_process_skill_content(content) + assert "user-invocable: true" in result + assert "disable-model-invocation: false" in result + assert "replace dots" in result + + +class TestSpeckitManifestRecordsSkippedFiles: + """Regression test for issue #2107. + + ``install_shared_infra`` must record every shared-infrastructure file + under ``.specify/`` in ``speckit.manifest.json``, including files that + were *skipped* because they already existed on disk and ``force=False``. + + Before the fix, the skip branches in the scripts and templates loops + appended to ``skipped_files`` without calling ``manifest.record_existing``. + So when ``install_shared_infra`` ran with a fresh (or lost) manifest + against an already-populated ``.specify/`` tree, every file went down the + skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and + ``manifest.save()`` wrote an empty ``files`` field — leaving the + integration believing nothing was installed. + + Reproduction (without the fix) using ``install_shared_infra`` directly: + + install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files + (p / ".specify/integrations/speckit.manifest.json").unlink() + install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files + # ^^ BUG: empty + """ + + def _read_manifest_files(self, project_path: Path) -> dict: + manifest_path = ( + project_path / ".specify" / "integrations" / "speckit.manifest.json" + ) + assert manifest_path.exists(), ( + f"speckit.manifest.json not written at {manifest_path}" + ) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + # ``IntegrationManifest.save`` serialises a ``files`` dict — assert + # the schema explicitly so a regression to a different key (e.g. + # the internal ``_files`` attribute name) fails loudly instead of + # being masked by a silent fallback. + assert isinstance(data, dict), ( + f"manifest root is not a dict, got {type(data).__name__}" + ) + assert "files" in data, ( + f"manifest missing 'files' key, got keys: {sorted(data.keys())}" + ) + files = data["files"] + assert isinstance(files, dict), ( + f"manifest 'files' is not a dict, got {type(files).__name__}" + ) + return files + + def test_install_shared_infra_records_skipped_files(self, tmp_path): + """With ``force=False`` and ``.specify/`` already populated, the + manifest must still record every file — the skip branches are not + allowed to drop files from the manifest.""" + from rich.console import Console + from specify_cli.shared_infra import install_shared_infra + + # Resolve the project's own packaged sources by walking up from this + # test file to the repo root (which contains ``scripts/`` and + # ``templates/`` that ``shared_scripts_source`` looks for). + repo_root = Path(__file__).resolve().parents[2] + console = Console(quiet=True) + + # First run — fresh project, manifest gets populated normally. + install_shared_infra( + tmp_path, + "sh", + version="0.0.0", + core_pack=None, + repo_root=repo_root, + console=console, + force=False, + ) + first_files = self._read_manifest_files(tmp_path) + assert first_files, "first install produced an empty manifest" + + # Simulate a lost manifest while ``.specify/`` is still on disk + # (e.g. the manifest was deleted, corrupted, or the layout was + # extracted out-of-band). + manifest_path = ( + tmp_path / ".specify" / "integrations" / "speckit.manifest.json" + ) + manifest_path.unlink() + + # Second run — every file already exists, so every iteration takes + # the skip branch. With the fix, those files are still recorded. + install_shared_infra( + tmp_path, + "sh", + version="0.0.0", + core_pack=None, + repo_root=repo_root, + console=console, + force=False, + ) + second_files = self._read_manifest_files(tmp_path) + assert second_files, ( + "speckit.manifest.json files dict is empty after install with " + "skipped files (issue #2107) — every file went down the skip " + "branch but none were recorded" + ) + + # The recovered manifest must cover everything the first run tracked. + missing = set(first_files) - set(second_files) + assert not missing, ( + f"these files were tracked on the first install but missing after " + f"the skipped-files re-install: {sorted(missing)[:5]}" + ) + + def test_install_shared_infra_handles_directory_at_script_destination( + self, tmp_path + ): + """A non-file (directory) at a script's destination must NOT crash + ``install_shared_infra`` and must NOT be recorded in the manifest — + the path still appears in the user-visible skipped-paths warning. + """ + from io import StringIO + from rich.console import Console + from specify_cli.shared_infra import install_shared_infra + + repo_root = Path(__file__).resolve().parents[2] + output = StringIO() + console = Console(file=output, force_terminal=False, width=200) + + # Pre-create the .specify/scripts/bash tree, then plant a directory + # where a script file is expected so the skip branch hits a + # non-regular-file path. + bash_dir = tmp_path / ".specify" / "scripts" / "bash" + bash_dir.mkdir(parents=True) + (bash_dir / "common.sh").mkdir() # collision: dir where file expected + + # Must not crash. + install_shared_infra( + tmp_path, + "sh", + version="0.0.0", + core_pack=None, + repo_root=repo_root, + console=console, + force=False, + ) + + files = self._read_manifest_files(tmp_path) + assert ".specify/scripts/bash/common.sh" not in files, ( + "directory at script dst must not be recorded in the manifest" + ) + text = output.getvalue() + assert "common.sh" in text, ( + "directory-at-script-dst path must surface in the skipped warning" + ) + + def test_install_shared_infra_handles_directory_at_template_destination( + self, tmp_path + ): + """Symmetric coverage for the templates loop: a directory at a + template's destination must NOT crash install nor be recorded.""" + from io import StringIO + from rich.console import Console + from specify_cli.shared_infra import install_shared_infra + + repo_root = Path(__file__).resolve().parents[2] + output = StringIO() + console = Console(file=output, force_terminal=False, width=200) + + templates_dir = tmp_path / ".specify" / "templates" + templates_dir.mkdir(parents=True) + + src_templates = repo_root / "templates" + real_template = next( + ( + p.name + for p in src_templates.iterdir() + if p.is_file() + and not p.name.startswith(".") + and p.name != "vscode-settings.json" + ), + None, + ) + assert real_template, ( + "no real template found in repo to collide against" + ) + (templates_dir / real_template).mkdir() # collision + + install_shared_infra( + tmp_path, + "sh", + version="0.0.0", + core_pack=None, + repo_root=repo_root, + console=console, + force=False, + ) + + files = self._read_manifest_files(tmp_path) + template_rel = f".specify/templates/{real_template}" + assert template_rel not in files, ( + "directory at template dst must not be recorded in manifest" + ) + text = output.getvalue() + assert real_template in text, ( + "directory-at-template-dst path must surface in the skipped warning" + )