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:
minbang
2026-06-04 23:53:31 +09:00
committed by GitHub
parent 8e5643d4ff
commit a9a759450d
16 changed files with 824 additions and 104 deletions

View File

@@ -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",
)

View 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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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.

View File

@@ -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, {})