mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user