mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
fix: recover active skills registration for extensions (#2803)
Extension command registration now resolves the active skills directory before writing command artifacts. This lets initialized skills-backed agents recover a missing active skills directory while preserving the existing preset registration behavior. Add regression coverage for missing active skills directories, shared skills directories, and symlinked parent guards. Fixes #2769. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
@@ -86,6 +86,12 @@ from ._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
from ._init_options import (
|
||||
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
|
||||
is_ai_skills_enabled as _is_ai_skills_enabled,
|
||||
load_init_options as load_init_options,
|
||||
save_init_options as save_init_options,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="specify",
|
||||
@@ -259,65 +265,6 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
||||
for f in failures:
|
||||
console.print(f" - {f}")
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``.
|
||||
|
||||
Writes a small JSON file to ``.specify/init-options.json`` so that
|
||||
later operations (e.g. preset install) can adapt their behaviour
|
||||
without scanning the filesystem.
|
||||
"""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write JSON as real UTF-8 instead of ``\uXXXX`` escape sequences
|
||||
# (``ensure_ascii=False``) and pin the file encoding to match.
|
||||
#
|
||||
# The default ``json.dumps`` output is ASCII-only — any non-ASCII
|
||||
# character is encoded as a ``\uXXXX`` escape — so without the
|
||||
# ``ensure_ascii=False`` flip below the encoding pin alone would be
|
||||
# a no-op for any payload we plausibly write today. We pair the two
|
||||
# so the on-disk bytes match a human's expectation of "this file is
|
||||
# UTF-8" (greppable, readable in editors that don't decode JSON
|
||||
# escapes, friendly to peers running ``cat`` or ``Get-Content``) and
|
||||
# so the encoding pin is a real contract instead of a future hedge.
|
||||
#
|
||||
# ``Path.write_text`` without ``encoding=`` falls back to the system
|
||||
# locale codec (cp1252 / gb2312 / cp932 on Windows), which would
|
||||
# mis-encode non-ASCII bytes locally and produce a file a peer with
|
||||
# a different locale couldn't decode. The sibling integration-
|
||||
# catalog writer in ``integrations/catalog.py`` pins
|
||||
# ``encoding="utf-8"`` for the same reason.
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
"""Load the init options previously saved by ``specify init``.
|
||||
|
||||
Returns an empty dict if the file does not exist or cannot be parsed.
|
||||
"""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
# Match the explicit UTF-8 used by ``save_init_options``; without
|
||||
# it ``read_text`` falls back to the system codec on Windows and
|
||||
# raises ``UnicodeDecodeError`` on any file containing the
|
||||
# multi-byte UTF-8 sequences ``save_init_options`` now writes
|
||||
# directly. ``UnicodeDecodeError`` is a subclass of
|
||||
# ``ValueError``, not ``OSError`` / ``json.JSONDecodeError``, so
|
||||
# it must be listed explicitly here to preserve the existing
|
||||
# "fall back to empty dict" contract for corrupted / foreign-
|
||||
# codec files.
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-context extension config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,10 +348,10 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
"""Return the active skills directory, creating it on demand when enabled.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills are
|
||||
enabled and which agent was selected. When ``ai_skills`` is true the
|
||||
directory is created safely (symlink/containment checks); when false
|
||||
only Kimi's native-skills fallback is honoured (directory must already
|
||||
exist).
|
||||
enabled and which agent was selected. Only ``ai_skills`` set to boolean
|
||||
``True`` creates the directory safely (symlink/containment checks); when
|
||||
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
|
||||
is honoured, and the native skills directory must already exist.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills are not active.
|
||||
@@ -425,14 +372,15 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
ai_skills_enabled = _is_ai_skills_enabled(opts)
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
return None
|
||||
|
||||
skills_dir = _get_skills_dir(project_root, agent)
|
||||
|
||||
if not ai_skills_enabled:
|
||||
# Kimi native-skills fallback: use the directory only if it exists.
|
||||
# Kimi native-skills fallback when ai_skills is not boolean True:
|
||||
# use the native skills directory only if it already exists.
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
_ensure_safe_shared_directory(
|
||||
@@ -441,7 +389,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
)
|
||||
return skills_dir
|
||||
|
||||
# ai_skills is explicitly enabled — create the directory safely.
|
||||
# ai_skills is boolean True: create the directory safely.
|
||||
_ensure_safe_shared_directory(
|
||||
project_root, skills_dir, context="agent skills directory",
|
||||
)
|
||||
|
||||
36
src/specify_cli/_init_options.py
Normal file
36
src/specify_cli/_init_options.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Helpers for interpreting persisted init options."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``."""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
"""Load persisted init options, returning an empty dict when unavailable."""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
|
||||
"""Return True only when init options explicitly enable AI skills."""
|
||||
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
|
||||
@@ -15,6 +15,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
||||
|
||||
|
||||
def _build_agent_configs() -> dict[str, Any]:
|
||||
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
|
||||
@@ -359,11 +361,6 @@ class CommandRegistrar:
|
||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
||||
) -> str:
|
||||
"""Resolve script placeholders for skills-backed agents."""
|
||||
try:
|
||||
from . import load_init_options
|
||||
except ImportError:
|
||||
return body
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
@@ -474,6 +471,29 @@ class CommandRegistrar:
|
||||
return False
|
||||
return os.path.normpath(name) == name
|
||||
|
||||
@staticmethod
|
||||
def _same_lexical_path(left: Path, right: Path) -> bool:
|
||||
"""Compare paths after lexical normalization without resolving symlinks."""
|
||||
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
|
||||
os.path.normpath(os.fspath(right))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _active_skills_agent(project_root: Path) -> Optional[str]:
|
||||
"""Return the initialized skills-backed agent, if skills mode is active."""
|
||||
opts = load_init_options(project_root)
|
||||
if not isinstance(opts, dict):
|
||||
return None
|
||||
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
# Kimi is a native skills integration; when ai_skills is not boolean
|
||||
# True, Kimi still uses its existing SKILL.md layout.
|
||||
if not is_ai_skills_enabled(opts) and agent != "kimi":
|
||||
return None
|
||||
return agent
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -806,6 +826,7 @@ class CommandRegistrar:
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -817,6 +838,11 @@ class CommandRegistrar:
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
create_missing_active_skills_dir: If True, attempt missing-dir
|
||||
recovery only for the active initialized skills-backed agent.
|
||||
Recovery requires active skills mode (or Kimi's existing native
|
||||
skills directory) and is skipped when safe resolution or
|
||||
creation fails.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -824,7 +850,17 @@ class CommandRegistrar:
|
||||
results = {}
|
||||
|
||||
self._ensure_configs()
|
||||
active_skills_agent = (
|
||||
self._active_skills_agent(project_root)
|
||||
if create_missing_active_skills_dir else None
|
||||
)
|
||||
active_created_skills_dir: Optional[Path] = None
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
active_skills_output = (
|
||||
agent_name == active_skills_agent
|
||||
and agent_config.get("extension") == "/SKILL.md"
|
||||
)
|
||||
recovered_active_skills_dir: Optional[Path] = None
|
||||
# Check detect_dir first (project-local marker) if configured,
|
||||
# falling back to the resolved dir for output. This prevents
|
||||
# global dirs (e.g. ~/.hermes/skills) from causing false
|
||||
@@ -832,13 +868,55 @@ class CommandRegistrar:
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.exists():
|
||||
continue
|
||||
if not detect_path.is_dir():
|
||||
if not active_skills_output:
|
||||
continue
|
||||
try:
|
||||
from . import resolve_active_skills_dir
|
||||
|
||||
recovered_active_skills_dir = (
|
||||
resolve_active_skills_dir(project_root)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if recovered_active_skills_dir is None or not detect_path.is_dir():
|
||||
continue
|
||||
active_created_skills_dir = recovered_active_skills_dir
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
|
||||
if agent_dir.exists():
|
||||
agent_dir_existed = agent_dir.is_dir()
|
||||
register_missing_active_skills_agent = (
|
||||
not agent_dir_existed
|
||||
and active_skills_output
|
||||
)
|
||||
if register_missing_active_skills_agent:
|
||||
if recovered_active_skills_dir is None:
|
||||
try:
|
||||
from . import resolve_active_skills_dir
|
||||
|
||||
recovered_active_skills_dir = (
|
||||
resolve_active_skills_dir(project_root)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if recovered_active_skills_dir is None:
|
||||
continue
|
||||
active_created_skills_dir = recovered_active_skills_dir
|
||||
# Shared skill dirs such as .agents/skills should not make
|
||||
# later integrations look detected when the active agent just
|
||||
# recreated the directory during this registration pass.
|
||||
created_by_active_agent = (
|
||||
active_created_skills_dir is not None
|
||||
and self._same_lexical_path(agent_dir, active_created_skills_dir)
|
||||
and agent_name != active_skills_agent
|
||||
)
|
||||
should_register = (
|
||||
agent_dir_existed and not created_by_active_agent
|
||||
) or register_missing_active_skills_agent
|
||||
|
||||
if should_register:
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
@@ -852,8 +930,16 @@ class CommandRegistrar:
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
if register_missing_active_skills_agent:
|
||||
active_created_skills_dir = (
|
||||
recovered_active_skills_dir or agent_dir
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
if register_missing_active_skills_agent:
|
||||
continue
|
||||
raise
|
||||
|
||||
return results
|
||||
|
||||
@@ -892,12 +978,12 @@ class CommandRegistrar:
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.exists():
|
||||
if not detect_path.is_dir():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
if agent_dir.exists():
|
||||
if agent_dir.is_dir():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
|
||||
@@ -26,14 +26,15 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
@@ -830,15 +831,53 @@ class ExtensionManager:
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import resolve_active_skills_dir, _print_cli_warning
|
||||
from . import (
|
||||
_print_cli_warning,
|
||||
load_init_options,
|
||||
resolve_active_skills_dir,
|
||||
)
|
||||
|
||||
def _ensure_usable(skills_dir: Path) -> Optional[Path]:
|
||||
try:
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not skills_dir.is_dir():
|
||||
raise NotADirectoryError(f"{skills_dir} is not a directory")
|
||||
except (OSError, ValueError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", str(skills_dir), exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
return skills_dir
|
||||
|
||||
try:
|
||||
return resolve_active_skills_dir(self.project_root)
|
||||
skills_dir = resolve_active_skills_dir(self.project_root)
|
||||
except (ValueError, OSError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", None, exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
if skills_dir is None:
|
||||
return None
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
return _ensure_usable(skills_dir)
|
||||
selected_ai = opts.get("ai")
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return _ensure_usable(skills_dir)
|
||||
|
||||
from .agents import CommandRegistrar
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
|
||||
if agent_config and agent_config.get("extension") == "/SKILL.md":
|
||||
agent_skills_dir = registrar._resolve_agent_dir(
|
||||
selected_ai, agent_config, self.project_root
|
||||
)
|
||||
return _ensure_usable(agent_skills_dir)
|
||||
return _ensure_usable(skills_dir)
|
||||
|
||||
def _register_extension_skills(
|
||||
self,
|
||||
@@ -1249,7 +1288,11 @@ class ExtensionManager:
|
||||
registrar = CommandRegistrar()
|
||||
# Register for all detected agents
|
||||
registered_commands = registrar.register_commands_for_all_agents(
|
||||
manifest, dest_dir, self.project_root, link_outputs=link_commands
|
||||
manifest,
|
||||
dest_dir,
|
||||
self.project_root,
|
||||
link_outputs=link_commands,
|
||||
create_missing_active_skills_dir=True,
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
@@ -1540,9 +1583,10 @@ class ExtensionManager:
|
||||
init_options = {}
|
||||
|
||||
active_agent = init_options.get("ai")
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_options)
|
||||
skills_mode_active = (
|
||||
active_agent == agent_name
|
||||
and bool(init_options.get("ai_skills"))
|
||||
and ai_skills_enabled
|
||||
and bool(agent_config)
|
||||
and agent_config.get("extension") != "/SKILL.md"
|
||||
)
|
||||
@@ -1736,6 +1780,7 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
@@ -1743,6 +1788,7 @@ class CommandRegistrar:
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
@@ -2530,10 +2576,11 @@ class HookExecutor:
|
||||
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
|
||||
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_options)
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
|
||||
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
|
||||
cline_mode = selected_ai == "cline"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
|
||||
@@ -34,6 +34,21 @@ _HOOK_COMMAND_NOTE = (
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
_CORE_COMMAND_TEMPLATE_ORDER = (
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
)
|
||||
_CORE_COMMAND_TEMPLATE_RANK = {
|
||||
command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationOption
|
||||
@@ -355,11 +370,19 @@ class IntegrationBase(ABC):
|
||||
return None
|
||||
|
||||
def list_command_templates(self) -> list[Path]:
|
||||
"""Return sorted list of command template files from the shared directory."""
|
||||
"""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")
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .integrations.base import IntegrationBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -1262,7 +1263,7 @@ class PresetManager:
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return []
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_opts)
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
|
||||
@@ -121,6 +121,11 @@ class TestBasePrimitives:
|
||||
assert len(templates) > 0
|
||||
assert all(t.suffix == ".md" for t in templates)
|
||||
|
||||
def test_list_command_templates_keeps_checklist_after_plan(self):
|
||||
i = StubIntegration()
|
||||
stems = [template.stem for template in i.list_command_templates()]
|
||||
assert stems.index("plan") < stems.index("checklist")
|
||||
|
||||
def test_command_filename_default(self):
|
||||
i = StubIntegration()
|
||||
assert i.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
@@ -254,8 +254,8 @@ class MarkdownIntegrationTests:
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
|
||||
@@ -100,8 +100,8 @@ class SkillsIntegrationTests:
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
|
||||
# Derive command names from the skill directory names
|
||||
@@ -393,8 +393,8 @@ class SkillsIntegrationTests:
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
|
||||
@@ -486,11 +486,11 @@ class TomlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -365,11 +365,11 @@ class YamlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -127,8 +127,8 @@ class TestCopilotIntegration:
|
||||
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
||||
assert len(agent_files) == 9
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
|
||||
assert actual_commands == expected_commands
|
||||
@@ -321,8 +321,8 @@ class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
|
||||
@@ -213,10 +213,10 @@ class TestGenericIntegration:
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -17,6 +17,7 @@ import tempfile
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from specify_cli.extensions import (
|
||||
ExtensionManifest,
|
||||
@@ -26,7 +27,9 @@ from specify_cli.extensions import (
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
||||
def _create_init_options(
|
||||
project_root: Path, ai: str = "claude", ai_skills: Any = True
|
||||
):
|
||||
"""Write a .specify/init-options.json file."""
|
||||
opts_dir = project_root / ".specify"
|
||||
opts_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,7 +38,7 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
|
||||
"ai": ai,
|
||||
"ai_skills": ai_skills,
|
||||
"script": "sh",
|
||||
}))
|
||||
}), encoding="utf-8")
|
||||
|
||||
|
||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||
@@ -220,11 +223,20 @@ class TestExtensionManagerGetSkillsDir:
|
||||
result = manager._get_skills_dir()
|
||||
assert result == skills_dir
|
||||
|
||||
def test_returns_none_when_ai_skills_is_non_boolean_truthy(self, project_dir):
|
||||
"""Corrupted truthy ai_skills values should not enable skills mode."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills="false")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
assert not (project_dir / ".claude" / "skills").exists()
|
||||
|
||||
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
@@ -655,6 +667,393 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_commands_registered_when_claude_skills_dir_missing(self, project_dir, temp_dir):
|
||||
"""Extension install should not silently skip Claude when skills dir is missing."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").mkdir()
|
||||
# Deliberately do NOT create .claude/skills
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {
|
||||
"claude": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
skill_file = skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: early-ext:commands/hello.md" in content
|
||||
|
||||
def test_hermes_global_skills_dir_used_when_marker_is_recovered(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Hermes recovery must not use the project marker as the output dir."""
|
||||
home = temp_dir / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"hermes": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
global_skills_dir = home / ".hermes" / "skills"
|
||||
assert (
|
||||
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
).exists()
|
||||
assert (
|
||||
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
marker = project_dir / ".hermes" / "skills"
|
||||
assert marker.is_dir()
|
||||
assert list(marker.glob("speckit-*/SKILL.md")) == []
|
||||
|
||||
def test_hermes_get_skills_dir_creates_global_output_dir(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""ExtensionManager should create the agent-specific output dir it returns."""
|
||||
home = temp_dir / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
skills_dir = manager._get_skills_dir()
|
||||
|
||||
assert skills_dir == home / ".hermes" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
assert (project_dir / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_unusable_hermes_global_skills_dir_skips_skill_registration(
|
||||
self, project_dir, temp_dir, monkeypatch, capsys
|
||||
):
|
||||
"""An unusable agent-specific output dir should warn and skip skills."""
|
||||
home = temp_dir / "home"
|
||||
hermes_dir = home / ".hermes"
|
||||
hermes_dir.mkdir(parents=True)
|
||||
(hermes_dir / "skills").write_text("not a directory", encoding="utf-8")
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="blocked-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_skills"] == []
|
||||
captured = capsys.readouterr()
|
||||
assert "Warning:" in captured.out
|
||||
assert "Continuing without skill registration." in captured.out
|
||||
|
||||
def test_detect_dir_marker_file_does_not_register_hermes_commands(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Regular files at detect_dir marker paths should not detect agents."""
|
||||
home = temp_dir / "home"
|
||||
global_skills_dir = home / ".hermes" / "skills"
|
||||
global_skills_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
marker_parent = project_dir / ".hermes"
|
||||
marker_parent.mkdir()
|
||||
marker_file = marker_parent / "skills"
|
||||
marker_file.write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert marker_file.is_file()
|
||||
assert marker_file.read_text(encoding="utf-8") == "not a directory"
|
||||
assert not (
|
||||
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
).exists()
|
||||
assert not (
|
||||
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_non_boolean_ai_skills_does_not_recover_missing_skills_dir(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Corrupted truthy ai_skills values should not recover skills dirs."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills="false")
|
||||
(project_dir / ".claude").mkdir()
|
||||
# Deliberately do NOT create .claude/skills.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert not (project_dir / ".claude" / "skills").exists()
|
||||
|
||||
def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Corrupted ai_skills values should not trigger skills-mode skips."""
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills="false")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"copilot": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert (project_dir / ".github" / "agents").is_dir()
|
||||
|
||||
def test_existing_agent_command_path_file_is_not_detected(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Existing files at command-dir paths should not count as detected agents."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
claude_dir = project_dir / ".claude"
|
||||
claude_dir.mkdir()
|
||||
skills_file = claude_dir / "skills"
|
||||
skills_file.write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert skills_file.read_text(encoding="utf-8") == "not a directory"
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_registers_only_active_agent(self, project_dir, temp_dir):
|
||||
"""Recreating shared skills dirs should not activate unrelated agents."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
# Deliberately do NOT create .agents/skills, shared by agy and codex.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {
|
||||
"agy": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_uses_normalized_guard_for_later_agents(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Shared-dir suppression should tolerate lexical path differences."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_resolve_agent_dir = AgentRegistrar._resolve_agent_dir
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
attempted_agents = []
|
||||
|
||||
def resolve_codex_with_parent_segment(self, agent_name, agent_config, root):
|
||||
if agent_name == "codex":
|
||||
return root / ".agents" / ".." / ".agents" / "skills"
|
||||
return original_resolve_agent_dir(agent_name, agent_config, root)
|
||||
|
||||
def record_registration(self, agent_name, *args, **kwargs):
|
||||
attempted_agents.append(agent_name)
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "_resolve_agent_dir", resolve_codex_with_parent_segment
|
||||
)
|
||||
monkeypatch.setattr(AgentRegistrar, "register_commands", record_registration)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert attempted_agents == ["agy"]
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"agy": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_write_oserror_does_not_register_other_agents(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Failed active registration must not make shared skills dirs detected."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
# Deliberately do NOT create .agents/skills, shared by agy and codex.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
attempted_agents = []
|
||||
|
||||
def fail_recovered_agy_registration(self, agent_name, *args, **kwargs):
|
||||
attempted_agents.append(agent_name)
|
||||
if agent_name == "agy":
|
||||
raise PermissionError("denied")
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "register_commands", fail_recovered_agy_registration
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
assert attempted_agents == ["agy"]
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_missing_active_skills_dir_does_not_follow_symlinked_parent(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Recovered command registration must reuse active skills-dir safety checks."""
|
||||
if not hasattr(os, "symlink"):
|
||||
pytest.skip("symlinks are unavailable")
|
||||
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
outside = temp_dir / "outside-claude"
|
||||
outside.mkdir()
|
||||
try:
|
||||
os.symlink(outside, project_dir / ".claude", target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("Current platform/user cannot create directory symlinks")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert not (outside / "skills").exists()
|
||||
|
||||
def test_missing_active_skills_dir_invalid_parent_skips_without_aborting(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Invalid active skill parents should not abort extension installation."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_active_skills_dir_write_oserror_skips_without_aborting(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Filesystem failures in recovered command registration should skip safely."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").mkdir()
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
|
||||
def fail_recovered_claude_registration(self, agent_name, *args, **kwargs):
|
||||
if agent_name == "claude":
|
||||
raise PermissionError("denied")
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "register_commands", fail_recovered_claude_registration
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
@@ -738,7 +1137,7 @@ class TestExtensionSkillEdgeCases:
|
||||
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
@@ -782,6 +782,71 @@ class TestExtensionManager:
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "commands" / "hello.md").exists()
|
||||
|
||||
def test_install_from_directory_explicitly_recovers_active_skills_dir(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""Extension install should explicitly request active skills-dir recovery."""
|
||||
captured = {}
|
||||
|
||||
def fake_register_all(
|
||||
self,
|
||||
manifest,
|
||||
extension_dir,
|
||||
project_root,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
CommandRegistrar,
|
||||
"register_commands_for_all_agents",
|
||||
fake_register_all,
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is True
|
||||
|
||||
def test_command_registrar_default_does_not_recover_active_skills_dir(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""The extension wrapper should preserve the core registrar's conservative default."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_register_all(
|
||||
self,
|
||||
commands,
|
||||
source_id,
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=None,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentCommandRegistrar,
|
||||
"register_commands_for_all_agents",
|
||||
fake_register_all,
|
||||
)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is False
|
||||
|
||||
def test_install_duplicate(self, extension_dir, project_dir):
|
||||
"""Test installing already installed extension."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -4884,6 +4949,26 @@ class TestHookInvocationRendering:
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "$speckit-tasks"
|
||||
|
||||
def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir):
|
||||
"""Corrupted truthy ai_skills values should not enable skill invocation."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(
|
||||
json.dumps({"ai": "codex", "ai_skills": "false"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.tasks",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "/speckit.tasks"
|
||||
|
||||
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
|
||||
"""Cline projects should render /speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
|
||||
@@ -2255,6 +2255,51 @@ class TestInitOptions:
|
||||
assert loaded["ai"] == "claude"
|
||||
assert loaded["ai_skills"] is True
|
||||
|
||||
def test_save_and_load_available_from_init_options_module(self, project_dir):
|
||||
from specify_cli._init_options import load_init_options, save_init_options
|
||||
|
||||
opts = {"ai": "codex", "ai_skills": True, "script": "sh"}
|
||||
save_init_options(project_dir, opts)
|
||||
|
||||
assert load_init_options(project_dir) == opts
|
||||
|
||||
def test_save_uses_utf8_encoding(self, project_dir, monkeypatch):
|
||||
from specify_cli import save_init_options
|
||||
|
||||
original_write_text = Path.write_text
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def spy_write_text(path, data, *args, **kwargs):
|
||||
if path == project_dir / ".specify" / "init-options.json":
|
||||
seen["encoding"] = kwargs.get("encoding")
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", spy_write_text)
|
||||
|
||||
save_init_options(project_dir, {"label": "中文测试"})
|
||||
|
||||
assert seen["encoding"] == "utf-8"
|
||||
|
||||
def test_load_uses_utf8_encoding(self, project_dir, monkeypatch):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text('{"ai": "codex"}', encoding="utf-8")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def spy_read_text(path, *args, **kwargs):
|
||||
if path == opts_file:
|
||||
seen["encoding"] = kwargs.get("encoding")
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", spy_read_text)
|
||||
|
||||
assert load_init_options(project_dir) == {"ai": "codex"}
|
||||
assert seen["encoding"] == "utf-8"
|
||||
|
||||
def test_load_returns_empty_when_missing(self, project_dir):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
@@ -2348,6 +2393,51 @@ class TestInitOptions:
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
@pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"])
|
||||
def test_load_returns_empty_on_non_object_json(self, project_dir, payload):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text(payload, encoding="utf-8")
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_bytes(b"{}")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def raise_decode_error(path, *args, **kwargs):
|
||||
if path == opts_file:
|
||||
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", raise_decode_error)
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
("true", False),
|
||||
("false", False),
|
||||
(1, False),
|
||||
(0, False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected):
|
||||
from specify_cli._init_options import is_ai_skills_enabled
|
||||
|
||||
assert is_ai_skills_enabled({"ai_skills": value}) is expected
|
||||
|
||||
|
||||
class TestPresetSkills:
|
||||
"""Tests for preset skill registration and unregistration.
|
||||
|
||||
Reference in New Issue
Block a user