diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py index dd225d8251..2391ff31e2 100644 --- a/src/specify_cli/_init_options.py +++ b/src/specify_cli/_init_options.py @@ -34,3 +34,28 @@ def load_init_options(project_path: Path) -> dict[str, Any]: 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 + + +def is_agent_skills_enabled(project_path: Path, agent_name: str, opts: Mapping[str, Any] | None) -> bool: + """Return True when *agent_name* should render extension skills. + + Prefers the per-agent ``skills`` flag recorded in + ``.specify/integration.json`` (``integration_settings[agent_name].parsed_options.skills``), + which reflects the ``--skills`` option passed to that specific agent's + install/upgrade/switch. Falls back to the legacy global + ``init-options.json`` ``ai_skills`` flag only when *agent_name* is the + active agent recorded there (pre-multi-install behaviour). + """ + from .integration_state import integration_setting, try_read_integration_json + + state, _error = try_read_integration_json(project_path) + if state: + setting = integration_setting(state, agent_name) + parsed = setting.get("parsed_options") + if isinstance(parsed, Mapping) and "skills" in parsed: + return parsed.get("skills") is True + + if isinstance(opts, Mapping) and opts.get("ai") == agent_name: + return is_ai_skills_enabled(opts) + + return False diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 05aa35f7fb..6943ac7b63 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -29,7 +29,11 @@ from .._assets import _locate_core_pack, _repo_root from .._init_options import is_ai_skills_enabled from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent -from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies +from .._utils import ( + dump_frontmatter, + relative_extension_path_violation, + version_satisfies, +) from ..catalogs import CatalogEntry as BaseCatalogEntry from ..catalogs import CatalogStackBase from ..shared_infra import verify_archive_sha256 @@ -910,8 +914,15 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]: return _ignore - def _get_skills_dir(self) -> Optional[Path]: - """Return the active skills directory for extension skill registration. + def _get_skills_dir(self, agent_name: str | None = None) -> Optional[Path]: + """Return the skills directory for extension skill registration. + + When *agent_name* is given, resolves the skills directory and + skills-enabled state for that specific agent (using per-agent + settings in ``.specify/integration.json``), so skill rendering + works correctly for non-active agents (#2948). When omitted, + falls back to the previous behaviour of using the active agent + from init-options. Delegates to :func:`resolve_active_skills_dir` which reads init-options, applies the Kimi native-skills fallback, and @@ -921,11 +932,15 @@ def _get_skills_dir(self) -> Optional[Path]: be created due to symlink, containment, or permission issues so that callers can fall back gracefully. """ + from .. import ( + _get_skills_dir as resolve_agent_skills_dir, + ) from .. import ( _print_cli_warning, load_init_options, resolve_active_skills_dir, ) + from .._init_options import is_agent_skills_enabled def _ensure_usable(skills_dir: Path) -> Optional[Path]: try: @@ -943,26 +958,61 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]: return None return skills_dir - try: - 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) + opts = {} + + if agent_name is None: + try: + 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 + selected_ai = opts.get("ai") + if not isinstance(selected_ai, str) or not selected_ai: + return _ensure_usable(skills_dir) + else: + from ..shared_infra import _ensure_safe_shared_directory + + ai_skills_enabled = is_agent_skills_enabled( + self.project_root, agent_name, opts + ) + if not ai_skills_enabled and agent_name != "kimi": + return None + try: + skills_dir = resolve_agent_skills_dir(self.project_root, agent_name) + if not ai_skills_enabled: + # Kimi native-skills fallback: only use the directory if + # it already exists; do not create it on demand. + if not skills_dir.is_dir(): + return None + _ensure_safe_shared_directory( + self.project_root, skills_dir, + create=False, context="agent skills directory", + ) + else: + _ensure_safe_shared_directory( + self.project_root, skills_dir, + context="agent skills directory", + ) + except (ValueError, OSError) as exc: + _print_cli_warning( + "resolve", + "skills directory", + None, + exc, + continuing="Continuing without skill registration.", + ) + return None + selected_ai = agent_name from ..agents import CommandRegistrar @@ -980,6 +1030,7 @@ def _register_extension_skills( manifest: ExtensionManifest, extension_dir: Path, link_outputs: bool = False, + agent_name: str | None = None, ) -> List[str]: """Generate SKILL.md files for extension commands as agent skills. @@ -997,11 +1048,12 @@ def _register_extension_skills( Returns: List of skill names that were created (for registry storage). """ - skills_dir = self._get_skills_dir() + skills_dir = self._get_skills_dir(agent_name) if not skills_dir: return [] from .. import load_init_options + from .._init_options import is_agent_skills_enabled from ..agents import CommandRegistrar from ..integrations import get_integration from ..integrations.base import IntegrationBase @@ -1010,13 +1062,13 @@ def _register_extension_skills( opts = load_init_options(self.project_root) if not isinstance(opts, dict): opts = {} - selected_ai = opts.get("ai") + selected_ai = agent_name if agent_name else opts.get("ai") if not isinstance(selected_ai, str) or not selected_ai: return [] registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) integration = get_integration(selected_ai) - ai_skills_enabled = is_ai_skills_enabled(opts) + ai_skills_enabled = is_agent_skills_enabled(self.project_root, selected_ai, opts) def _resolve_command_ref_tokens(body: str) -> str: """Resolve explicit command-ref tokens with the active skill style.""" @@ -1164,6 +1216,73 @@ def _replacement(match: re.Match[str]) -> str: return written + def _register_extension_skills_for_installed_agents( + self, + manifest: ExtensionManifest, + extension_dir: Path, + link_outputs: bool = False, + ) -> list[str]: + """Render extension skills for every skills-mode agent detected. + + Used by paths (like ``extension add``) that register an extension + for all agents at once, rather than a single explicit target agent. + Checks the active agent (legacy single-agent projects) plus every + agent recorded in ``.specify/integration.json``'s installed + integrations, and renders skills for whichever of those have skills + mode enabled for them specifically, instead of only the active + agent (#2948). + """ + from .. import load_init_options + from ..integration_state import ( + installed_integration_keys, + try_read_integration_json, + ) + + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + opts = {} + + candidate_agents: list[str] = [] + active_agent = opts.get("ai") + if isinstance(active_agent, str) and active_agent: + candidate_agents.append(active_agent) + + state, _error = try_read_integration_json(self.project_root) + if state: + for key in installed_integration_keys(state): + if key not in candidate_agents: + candidate_agents.append(key) + + combined: list[str] = [] + seen = set() + for agent_name in candidate_agents: + try: + agent_skills = self._register_extension_skills( + manifest, + extension_dir, + link_outputs=link_outputs, + agent_name=agent_name, + ) + except Exception as skills_err: # noqa: BLE001 -- best-effort per-agent registration + from .. import _print_cli_warning + _print_cli_warning( + "register extension skills for", + "extension", + manifest.id, + skills_err, + continuing=( + "Continuing with available registration results for " + "this extension and the remaining agents." + ), + ) + continue + for skill_name in agent_skills: + if skill_name not in seen: + seen.add(skill_name) + combined.append(skill_name) + + return combined + @staticmethod def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool: """Return True when an existing skill file links to its dev cache.""" @@ -1180,29 +1299,39 @@ def _unregister_extension_skills( skill_names: List[str], extension_id: str, skills_dir: Optional[Path] = None, + scan_all_agents: bool = False, ) -> None: """Remove SKILL.md directories for extension skills. Called during extension removal to clean up skill files that were created by ``_register_extension_skills()``. - If *skills_dir* is not provided and ``_get_skills_dir()`` returns - ``None`` (e.g. the user removed init-options.json or toggled - ai_skills after installation), we fall back to scanning all known - agent skills directories so that orphaned skill directories are - still cleaned up. In that case each candidate directory is - verified against the SKILL.md ``metadata.source`` field before - removal to avoid accidentally deleting user-created skills with - the same name. + By default this is agent-scoped: it only touches *skills_dir* (or + the resolved active-agent directory when *skills_dir* is not + given), falling back to scanning every known agent skills + directory only when no directory could be resolved at all. Pass + *scan_all_agents* to always scan every known agent directory in + addition to *skills_dir* — used for a full extension removal, + since skills may have been rendered for more than one agent + (#2948) and all of them need cleaning up, not just the active + one. Each candidate directory is verified against the SKILL.md + ``metadata.source`` field before removal to avoid accidentally + deleting user-created skills with the same name. Args: skill_names: List of skill names to remove. extension_id: Extension ID used to verify ownership during - fallback candidate scanning. + candidate scanning. skills_dir: Optional explicit skills directory to use instead of resolving via ``_get_skills_dir()``. Useful when the caller needs to target a specific agent's skills directory regardless of the currently-active agent in init-options. + scan_all_agents: If True, always additionally scan every + known agent skills directory, not just *skills_dir* (or + its resolved fallback). Use only for full extension + removal, never for single-agent-scoped cleanup — doing so + for the latter would delete other agents' unrelated + skills of the same name. """ if not skill_names: return @@ -1252,8 +1381,15 @@ def _unregister_extension_skills( except (OSError, UnicodeDecodeError, Exception): continue shutil.rmtree(skill_subdir) - else: - # Fallback: scan all possible agent skills directories + # Only additionally scan every other known agent skills directory + # when no directory could be resolved at all (legacy fallback), or + # when the caller explicitly asked for a full multi-agent scan + # (scan_all_agents=True, used for full extension removal since + # skills may have been rendered for more than one agent, #2948). + # A single-agent-scoped caller (skills_dir given, scan_all_agents + # False) must not have this broader scan touch other agents' + # unrelated skills of the same name. + if not skills_dir or scan_all_agents: from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR candidate_dirs: set[Path] = set() @@ -1448,9 +1584,11 @@ def install_from_directory( create_missing_active_skills_dir=True, ) - # Auto-register extension commands as agent skills when skills mode - # was used during project initialisation (feature parity). - registered_skills = self._register_extension_skills( + # Auto-register extension commands as agent skills for every + # skills-mode agent detected (active agent plus any other + # installed integrations in skills mode), not just the active + # agent (#2948). + registered_skills = self._register_extension_skills_for_installed_agents( manifest, dest_dir, link_outputs=link_commands ) @@ -1596,7 +1734,12 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: registrar.unregister_commands(registered_commands, self.project_root) # Unregister agent skills - self._unregister_extension_skills(registered_skills, extension_id) + # scan_all_agents=True: this is a full extension removal, so skills + # rendered for any installed agent (not just the active one) need + # to be cleaned up (#2948). + self._unregister_extension_skills( + registered_skills, extension_id, scan_all_agents=True + ) if keep_config: # Preserve config files, only remove non-config files @@ -1731,17 +1874,16 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: def register_enabled_extensions_for_agent(self, agent_name: str) -> None: """Register installed, enabled extensions for ``agent_name``. - Command-file registration is scoped to the explicit ``agent_name`` - argument, so this method can be used after install, upgrade, or switch. - Extension skill rendering is still scoped to the active ``ai`` / - ``ai_skills`` settings in init-options, so non-active skills-mode - targets receive command files here. Per-agent skills parity is tracked - separately in #2948. + Both command-file and skill registration are scoped to the explicit + ``agent_name`` argument, so this method can be used after install, + upgrade, or switch and renders skills correctly for any enabled + skills-mode agent, not just the active one (#2948). """ if not agent_name: return from .. import load_init_options + from .._init_options import is_agent_skills_enabled registrar = CommandRegistrar() agent_config = registrar.AGENT_CONFIGS.get(agent_name) @@ -1749,11 +1891,11 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: if not isinstance(init_options, dict): init_options = {} - active_agent = init_options.get("ai") - ai_skills_enabled = is_ai_skills_enabled(init_options) + ai_skills_enabled = is_agent_skills_enabled( + self.project_root, agent_name, init_options + ) skills_mode_active = ( - active_agent == agent_name - and ai_skills_enabled + ai_skills_enabled and bool(agent_config) and agent_config.get("extension") != "/SKILL.md" ) @@ -1793,46 +1935,42 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None: if new_registered != registered_commands: updates["registered_commands"] = new_registered - # Extension *skills* are only ever rendered for the active agent: - # `_register_extension_skills` resolves the skills dir and - # frontmatter from init-options["ai"], ignoring ``agent_name``. - # When this method runs for a non-active agent — as install/upgrade - # now do for a secondary integration (#2886) — the skills pass would - # re-render the *active* agent's extension skills as a side effect, - # resurrecting skill files the user deliberately deleted. Skip it - # unless the target is the active agent; `switch` is unaffected - # because it activates the target before registering. (Rendering - # skills for a non-active target is tracked separately in #2948.) - if agent_name == active_agent: - try: - registered_skills = self._register_extension_skills( - manifest, ext_dir + # Extension skills are rendered whenever *agent_name* itself + # has skills mode enabled (checked via per-agent settings in + # `.specify/integration.json`, falling back to the legacy + # global init-options for the active agent). This makes + # skill rendering agent-aware instead of only ever + # targeting the active agent (#2948), while still avoiding + # unrelated side effects on other installed agents. + try: + registered_skills = self._register_extension_skills( + manifest, ext_dir, agent_name=agent_name + ) + except Exception as skills_err: + # Skills are a companion artifact. If command registration + # already succeeded, still persist it so later cleanup can + # find those command files. + from .. import _print_cli_warning + + _print_cli_warning( + "register extension skills for", + "extension", + ext_id, + skills_err, + continuing=( + "Continuing with available registration results for this " + "extension and the remaining extensions." + ), + ) + else: + if registered_skills: + existing_skills = self._valid_name_list( + metadata.get("registered_skills", []) ) - except Exception as skills_err: - # Skills are a companion artifact. If command registration - # already succeeded, still persist it so later cleanup can - # find those command files. - from .. import _print_cli_warning - - _print_cli_warning( - "register extension skills for", - "extension", - ext_id, - skills_err, - continuing=( - "Continuing with available registration results for this " - "extension and the remaining extensions." - ), + merged_skills = list( + dict.fromkeys(existing_skills + registered_skills) ) - else: - if registered_skills: - existing_skills = self._valid_name_list( - metadata.get("registered_skills", []) - ) - merged_skills = list( - dict.fromkeys(existing_skills + registered_skills) - ) - updates["registered_skills"] = merged_skills + updates["registered_skills"] = merged_skills if updates: self.registry.update(ext_id, updates) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 07a62efeed..b79a1710ce 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -402,14 +402,12 @@ def _register_extensions_for_agent( integration has no extension side effects until it is selected or upgraded. See issue #2886. - Known limitation: extension *skill* rendering is scoped to the active - agent (init-options track a single ``ai`` / ``ai_skills`` pair). A - skills-mode agent registered while it is *not* the active agent (e.g. - Copilot ``--skills`` registered while non-active) therefore - receives command files rather than skills here — matching ``extension - add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they - make the target the active agent first. Per-agent skills parity is tracked in - #2948. + Extension *skill* rendering is scoped to ``agent_key`` itself (using + per-agent settings in ``.specify/integration.json`` when available, with + a fallback to the legacy global init-options for the active agent), so a + skills-mode agent registered while it is not the active agent (e.g. + Copilot ``--skills`` registered while non-active) still receives skill + files here instead of only command files (#2948). Best-effort: never aborts the surrounding integration operation. Callers invoke it *after* the use/upgrade/switch transaction has committed so a diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index dea42a3852..3cd7f6780b 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -219,6 +219,40 @@ def no_skills_project(project_dir): return project_dir +def _create_integration_json( + project_root: Path, + *, + default_agent: str, + installed: list, + skills_by_agent: dict, +): + """Write a .specify/integration.json with per-agent skills settings. + + ``skills_by_agent`` maps agent key -> bool for + ``integration_settings[agent].parsed_options.skills``. + """ + specify_dir = project_root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + settings = {} + for agent in installed: + entry = {"script": "sh", "invoke_separator": "-"} + if agent in skills_by_agent: + entry["raw_options"] = "--skills" if skills_by_agent[agent] else "" + entry["parsed_options"] = {"skills": skills_by_agent[agent]} + settings[agent] = entry + payload = { + "version": "0.1.0", + "integration_state_schema": 1, + "installed_integrations": installed, + "integration_settings": settings, + "integration": default_agent, + "default_integration": default_agent, + } + (specify_dir / "integration.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + # ===== ExtensionManager._get_skills_dir Tests ===== class TestExtensionManagerGetSkillsDir: @@ -1906,3 +1940,174 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension assert result is True assert not (skills_dir / "speckit-test-ext-hello").exists() assert not (skills_dir / "speckit-test-ext-world").exists() + + +# ===== Per-agent (non-active) skills mode tests (#2948) ===== +class TestNonActiveAgentSkillRegistration: + """Skills should render for any skills-mode agent, not just the active one.""" + + def test_register_enabled_extensions_for_agent_renders_non_active_agent_skills( + self, project_dir, extension_dir + ): + """upgrade/install-style registration should target the given agent.""" + _create_init_options(project_dir, ai="claude", ai_skills=False) + _create_integration_json( + project_dir, + default_agent="claude", + installed=["claude", "copilot"], + skills_by_agent={"copilot": True}, + ) + copilot_skills_dir = _create_skills_dir(project_dir, ai="copilot") + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + manifest = manager.get_extension("test-ext") + + manager.register_enabled_extensions_for_agent("copilot") + + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert ( + copilot_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + + def test_register_enabled_extensions_for_agent_does_not_affect_active_agent( + self, project_dir, extension_dir + ): + """Rendering skills for a non-active agent must not touch the active agent's own skills.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_integration_json( + project_dir, + default_agent="claude", + installed=["claude", "copilot"], + skills_by_agent={"copilot": True}, + ) + copilot_skills_dir = _create_skills_dir(project_dir, ai="copilot") + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Simulate the user deleting the claude skill files before re-running + # registration for a different (copilot) agent. + claude_skill_dir = claude_skills_dir / "speckit-test-ext-hello" + shutil.rmtree(claude_skill_dir) + assert not claude_skill_dir.exists() + + manager.register_enabled_extensions_for_agent("copilot") + + # Copilot's skills were rendered... + assert ( + copilot_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + # ...but claude's deleted skill was not resurrected. + assert not claude_skill_dir.exists() + + def test_extension_add_renders_skills_for_all_installed_skills_mode_agents( + self, project_dir, extension_dir + ): + """extension add should render skills for every installed skills-mode agent.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_integration_json( + project_dir, + default_agent="claude", + installed=["claude", "copilot"], + skills_by_agent={"copilot": True}, + ) + copilot_skills_dir = _create_skills_dir(project_dir, ai="copilot") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert ( + claude_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + assert ( + copilot_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + + def test_remove_cleans_up_skills_for_non_active_agent( + self, project_dir, extension_dir + ): + """remove() must clean up skills rendered for a non-active agent too (#2948).""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_integration_json( + project_dir, + default_agent="claude", + installed=["claude", "copilot"], + skills_by_agent={"copilot": True}, + ) + copilot_skills_dir = _create_skills_dir(project_dir, ai="copilot") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Precondition: both agents got the skill rendered. + assert ( + claude_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + assert ( + copilot_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + # Removal must clean up both agents' skill directories, not just + # the active one. + assert not ( + claude_skills_dir / "speckit-test-ext-hello" + ).exists() + assert not ( + copilot_skills_dir / "speckit-test-ext-hello" + ).exists() + + def test_unregister_agent_artifacts_does_not_touch_other_agents_skills( + self, project_dir, extension_dir + ): + """Cleaning up one agent's artifacts must not delete another agent's skills.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = _create_skills_dir(project_dir, ai="claude") + _create_integration_json( + project_dir, + default_agent="claude", + installed=["claude", "copilot"], + skills_by_agent={"copilot": True}, + ) + copilot_skills_dir = _create_skills_dir(project_dir, ai="copilot") + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Precondition: both agents got the skill rendered. + assert ( + claude_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + assert ( + copilot_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists() + + # Simulate switching away from / uninstalling copilot only. + manager.unregister_agent_artifacts("copilot") + + # Copilot's skill is gone, but claude's is untouched. + assert not ( + copilot_skills_dir / "speckit-test-ext-hello" + ).exists() + assert ( + claude_skills_dir / "speckit-test-ext-hello" / "SKILL.md" + ).exists()