Skip to content

Commit e54653e

Browse files
authored
fix: create skills directory on demand during extension/preset install (#2711)
* fix: create skills directory on demand during extension/preset install _get_skills_dir() in both extensions.py and presets.py returned None when the skills directory did not yet exist on disk, even though skills were enabled in init-options. This caused extension skill registration to silently produce an empty registered_skills list and skip writing SKILL.md files. Replace the is_dir() bail-out with mkdir(parents=True, exist_ok=True) so the directory is created on demand when ai_skills is enabled. Update the existing test expectation and add a parametrized regression test (claude + codex) that installs an extension before the skills directory exists and asserts SKILL.md files and registry entries are created. Fixes #2682 * test: assert skills dir is NOT created when skills are disabled Strengthen negative tests to verify _get_skills_dir does not create the directory on disk when ai_skills is false or init-options.json is absent. * fix: add symlink/containment check and preserve Kimi existence gate Address PR review feedback: - Use _ensure_safe_shared_directory() instead of raw mkdir() to prevent symlink-following writes outside the project root. - Restore the is_dir() existence gate for the Kimi native-skills fallback (ai_skills=false): only create the directory on demand when ai_skills is explicitly enabled. - Update docstrings to reflect the on-demand vs existence-gate behavior. - Reuse resolve_skills_dir helper in tests instead of manually reconstructing paths from AGENT_CONFIG. * refactor: extract resolve_active_skills_dir shared helper Deduplicate the _get_skills_dir logic that was nearly identical in ExtensionManager and PresetManager into a single module-level resolve_active_skills_dir() function in __init__.py. The shared helper wraps _ensure_safe_shared_directory errors with skills-specific messages so users see 'agent skills directory' instead of 'shared infrastructure directory' in error output. Both class methods now delegate to the shared helper. * fix: preserve original error reason in skills dir safety check Include the original exception message from _ensure_safe_shared_directory in the re-raised ValueError so the user sees the specific reason (symlink, not-a-directory, path escape, etc.) instead of a generic message. * fix: handle skills dir safety errors gracefully during install Catch ValueError/OSError from _get_skills_dir() inside _register_extension_skills() so a symlink or permission error logs a warning and returns [] instead of aborting mid-install and leaving a partially-installed extension without a registry entry. Also document OSError in resolve_active_skills_dir() docstring. * fix: catch errors in _get_skills_dir and use _print_cli_warning Move the ValueError/OSError catch from _register_extension_skills into _get_skills_dir itself so all callers (install, uninstall, reconcile) are protected from unsafe-path exceptions. Replace logging.getLogger().warning with _print_cli_warning for consistent Rich-formatted user output. * fix: use context-aware error messages for skills directory safety Add a 'context' parameter to _ensure_safe_shared_directory (defaults to 'shared infrastructure directory' for backward compat). The skills dir caller passes context='agent skills directory' so error messages say e.g. 'Refusing to use symlinked agent skills directory' instead of 'Refusing to use symlinked shared infrastructure directory'. Simplify resolve_active_skills_dir by removing the now-unnecessary try/except wrapper. * fix: validate Kimi native-skills directory for symlink/containment The Kimi fallback path (ai_skills=false) used is_dir() which follows symlinks, so a symlinked .kimi/skills could cause writes outside the project root. Now validates with _ensure_safe_shared_directory(create= False) before returning the directory.
1 parent c7e0cac commit e54653e

5 files changed

Lines changed: 138 additions & 68 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,57 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
317317
return project_path / ".agents" / "skills"
318318

319319

320+
def resolve_active_skills_dir(project_root: Path) -> Path | None:
321+
"""Return the active skills directory, creating it on demand when enabled.
322+
323+
Reads ``.specify/init-options.json`` to determine whether skills are
324+
enabled and which agent was selected. When ``ai_skills`` is true the
325+
directory is created safely (symlink/containment checks); when false
326+
only Kimi's native-skills fallback is honoured (directory must already
327+
exist).
328+
329+
Returns:
330+
The skills directory ``Path``, or ``None`` if skills are not active.
331+
332+
Raises:
333+
ValueError: If the resolved skills path escapes the project root,
334+
a parent component is a symlink, or a path component exists
335+
but is not a directory.
336+
OSError: If the directory cannot be created (e.g. permission denied).
337+
"""
338+
from .shared_infra import _ensure_safe_shared_directory
339+
340+
opts = load_init_options(project_root)
341+
if not isinstance(opts, dict):
342+
opts = {}
343+
344+
agent = opts.get("ai")
345+
if not isinstance(agent, str) or not agent:
346+
return None
347+
348+
ai_skills_enabled = bool(opts.get("ai_skills"))
349+
if not ai_skills_enabled and agent != "kimi":
350+
return None
351+
352+
skills_dir = _get_skills_dir(project_root, agent)
353+
354+
if not ai_skills_enabled:
355+
# Kimi native-skills fallback: use the directory only if it exists.
356+
if not skills_dir.is_dir():
357+
return None
358+
_ensure_safe_shared_directory(
359+
project_root, skills_dir,
360+
create=False, context="agent skills directory",
361+
)
362+
return skills_dir
363+
364+
# ai_skills is explicitly enabled — create the directory safely.
365+
_ensure_safe_shared_directory(
366+
project_root, skills_dir, context="agent skills directory",
367+
)
368+
return skills_dir
369+
370+
320371
def _cli_error_detail(exc: BaseException) -> str:
321372
"""Return a compact one-line exception detail for CLI output."""
322373
detail = str(exc).replace("\n", " ").strip()

src/specify_cli/extensions.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -801,38 +801,24 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:
801801
def _get_skills_dir(self) -> Optional[Path]:
802802
"""Return the active skills directory for extension skill registration.
803803
804-
Reads ``.specify/init-options.json`` to determine whether skills
805-
are enabled and which agent was selected, then delegates to
806-
the module-level ``_get_skills_dir()`` helper for the concrete path.
804+
Delegates to :func:`resolve_active_skills_dir` which reads
805+
init-options, applies the Kimi native-skills fallback, and
806+
safely creates the directory when ``ai_skills`` is enabled.
807807
808-
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
809-
``.kimi/skills`` exists, extension installs should still propagate
810-
command skills even when ``ai_skills`` is false.
811-
812-
Returns:
813-
The skills directory ``Path``, or ``None`` if skills were not
814-
enabled and no native-skills fallback applies.
808+
Returns ``None`` (instead of raising) when the directory cannot
809+
be created due to symlink, containment, or permission issues so
810+
that callers can fall back gracefully.
815811
"""
816-
from . import load_init_options, _get_skills_dir as resolve_skills_dir
817-
818-
opts = load_init_options(self.project_root)
819-
if not isinstance(opts, dict):
820-
opts = {}
821-
822-
agent = opts.get("ai")
823-
if not isinstance(agent, str) or not agent:
824-
return None
825-
826-
ai_skills_enabled = bool(opts.get("ai_skills"))
827-
if not ai_skills_enabled and agent != "kimi":
828-
return None
829-
830-
skills_dir = resolve_skills_dir(self.project_root, agent)
831-
if not skills_dir.is_dir():
812+
from . import resolve_active_skills_dir, _print_cli_warning
813+
try:
814+
return resolve_active_skills_dir(self.project_root)
815+
except (ValueError, OSError) as exc:
816+
_print_cli_warning(
817+
"resolve", "skills directory", None, exc,
818+
continuing="Continuing without skill registration.",
819+
)
832820
return None
833821

834-
return skills_dir
835-
836822
def _register_extension_skills(
837823
self,
838824
manifest: ExtensionManifest,

src/specify_cli/presets.py

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,37 +1097,24 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
10971097
def _get_skills_dir(self) -> Optional[Path]:
10981098
"""Return the active skills directory for preset skill overrides.
10991099
1100-
Reads ``.specify/init-options.json`` to determine whether skills
1101-
are enabled and which agent was selected, then delegates to
1102-
the module-level ``_get_skills_dir()`` helper for the concrete path.
1100+
Delegates to :func:`resolve_active_skills_dir` which reads
1101+
init-options, applies the Kimi native-skills fallback, and
1102+
safely creates the directory when ``ai_skills`` is enabled.
11031103
1104-
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
1105-
``.kimi/skills`` exists, presets should still propagate command
1106-
overrides to skills even when ``ai_skills`` is false.
1107-
1108-
Returns:
1109-
The skills directory ``Path``, or ``None`` if skills were not
1110-
enabled and no native-skills fallback applies.
1104+
Returns ``None`` (instead of raising) when the directory cannot
1105+
be created due to symlink, containment, or permission issues so
1106+
that callers can fall back gracefully.
11111107
"""
1112-
from . import load_init_options, _get_skills_dir
1113-
1114-
opts = load_init_options(self.project_root)
1115-
if not isinstance(opts, dict):
1116-
opts = {}
1117-
agent = opts.get("ai")
1118-
if not isinstance(agent, str) or not agent:
1119-
return None
1120-
1121-
ai_skills_enabled = bool(opts.get("ai_skills"))
1122-
if not ai_skills_enabled and agent != "kimi":
1123-
return None
1124-
1125-
skills_dir = _get_skills_dir(self.project_root, agent)
1126-
if not skills_dir.is_dir():
1108+
from . import resolve_active_skills_dir, _print_cli_warning
1109+
try:
1110+
return resolve_active_skills_dir(self.project_root)
1111+
except (ValueError, OSError) as exc:
1112+
_print_cli_warning(
1113+
"resolve", "skills directory", None, exc,
1114+
continuing="Continuing without skill registration.",
1115+
)
11271116
return None
11281117

1129-
return skills_dir
1130-
11311118
@staticmethod
11321119
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
11331120
"""Return the modern and legacy skill directory names for a command."""

src/specify_cli/shared_infra.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,13 @@ def _shared_relative_path(project_path: Path, dest: Path) -> Path:
8888
return rel
8989

9090

91-
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
91+
def _ensure_safe_shared_directory(
92+
project_path: Path,
93+
directory: Path,
94+
*,
95+
create: bool = True,
96+
context: str = "shared infrastructure directory",
97+
) -> None:
9298
"""Create a shared infra directory without following symlinked parents."""
9399
root = project_path.resolve()
94100
rel = _shared_relative_path(project_path, directory)
@@ -98,24 +104,24 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
98104
current = current / part
99105
label = _shared_destination_label(project_path, current)
100106
if current.is_symlink():
101-
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
107+
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
102108
if current.exists():
103109
if not current.is_dir():
104-
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
110+
raise ValueError(f"{context.capitalize()} path is not a directory: {label}")
105111
try:
106112
current.resolve().relative_to(root)
107113
except (OSError, ValueError):
108-
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
114+
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
109115
continue
110116
if not create:
111-
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
117+
raise ValueError(f"{context.capitalize()} does not exist: {label}")
112118
current.mkdir()
113119
if current.is_symlink():
114-
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
120+
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
115121
try:
116122
current.resolve().relative_to(root)
117123
except (OSError, ValueError):
118-
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
124+
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
119125

120126

121127
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:

tests/test_extension_skills.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,24 +173,32 @@ def test_returns_skills_dir_when_active(self, skills_project):
173173
assert result == skills_dir
174174

175175
def test_returns_none_when_no_ai_skills(self, no_skills_project):
176-
"""Should return None when ai_skills is false."""
176+
"""Should return None when ai_skills is false and not create the dir."""
177177
manager = ExtensionManager(no_skills_project)
178178
result = manager._get_skills_dir()
179179
assert result is None
180+
# Ensure the directory was NOT created on disk
181+
from specify_cli import _get_skills_dir as resolve_skills_dir
182+
skills_path = resolve_skills_dir(no_skills_project, "claude")
183+
assert not skills_path.exists()
180184

181185
def test_returns_none_when_no_init_options(self, project_dir):
182-
"""Should return None when init-options.json is missing."""
186+
"""Should return None when init-options.json is missing and not create any dir."""
183187
manager = ExtensionManager(project_dir)
184188
result = manager._get_skills_dir()
185189
assert result is None
190+
# No agent skills directory should have been created
191+
assert not (project_dir / ".claude" / "skills").exists()
192+
assert not (project_dir / ".agents" / "skills").exists()
186193

187-
def test_returns_none_when_skills_dir_missing(self, project_dir):
188-
"""Should return None when skills dir doesn't exist on disk."""
194+
def test_creates_skills_dir_on_demand(self, project_dir):
195+
"""Should create skills dir when ai_skills is enabled but dir is missing."""
189196
_create_init_options(project_dir, ai="claude", ai_skills=True)
190-
# Don't create the skills directory
197+
# Don't create the skills directory — _get_skills_dir should do it
191198
manager = ExtensionManager(project_dir)
192199
result = manager._get_skills_dir()
193-
assert result is None
200+
assert result is not None
201+
assert result.is_dir()
194202

195203
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
196204
"""Kimi should still use its native skills dir when ai_skills is false."""
@@ -460,6 +468,38 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir):
460468
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
461469
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
462470

471+
@pytest.mark.parametrize("ai", ["claude", "codex"])
472+
def test_skills_registered_when_dir_missing(self, project_dir, temp_dir, ai):
473+
"""Extension add should create skills dir on demand and register skills.
474+
475+
Regression test for https://github.com/github/spec-kit/issues/2682:
476+
when an extension is installed before the agent skills directory exists,
477+
skills must still be materialized (the directory is created on demand).
478+
"""
479+
_create_init_options(project_dir, ai=ai, ai_skills=True)
480+
# Deliberately do NOT create the skills directory
481+
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
482+
483+
manager = ExtensionManager(project_dir)
484+
manifest = manager.install_from_directory(
485+
ext_dir, "0.1.0", register_commands=False
486+
)
487+
488+
# Skills dir should have been created automatically
489+
from specify_cli import _get_skills_dir as resolve_skills_dir
490+
skills_dir = resolve_skills_dir(project_dir, ai)
491+
assert skills_dir.is_dir()
492+
493+
# SKILL.md files should exist
494+
assert (skills_dir / "speckit-early-ext-hello" / "SKILL.md").exists()
495+
assert (skills_dir / "speckit-early-ext-world" / "SKILL.md").exists()
496+
497+
# Registry should record them
498+
metadata = manager.registry.get(manifest.id)
499+
assert len(metadata["registered_skills"]) == 2
500+
assert "speckit-early-ext-hello" in metadata["registered_skills"]
501+
assert "speckit-early-ext-world" in metadata["registered_skills"]
502+
463503

464504
# ===== Extension Skill Unregistration Tests =====
465505

0 commit comments

Comments
 (0)