diff --git a/README.md b/README.md index 006d55227..d1e3cd25e 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) | +| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | ### Examples diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index ae77860fe..961c58fe9 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -187,6 +187,21 @@ Provided commands: Check: .specify/extensions/jira/ ``` +### Automatic Agent Skill Registration + +If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. + +```text +✓ Extension installed successfully! + +Jira Integration (v1.0.0) + ... + +✓ 3 agent skill(s) auto-registered +``` + +When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten. + --- ## Using Extensions diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a45535aee..e72257d4c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2884,6 +2884,12 @@ def extension_add( for cmd in manifest.commands: console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + if reg_skills: + console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{manifest.id}/") @@ -2922,14 +2928,18 @@ def extension_remove( installed = manager.list_installed() extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - # Get extension info for command count + # Get extension info for command and skill counts ext_manifest = manager.get_extension(extension_id) cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + reg_meta = manager.registry.get(extension_id) + skill_count = len(reg_meta.get("registered_skills", [])) if reg_meta else 0 # Confirm removal if not force: console.print("\n[yellow]⚠ This will remove:[/yellow]") console.print(f" • {cmd_count} commands from AI agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") if not keep_config: console.print(" • Config files (will be backed up)") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 0dfd40b7c..660d1d1df 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -402,6 +402,185 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]: return _ignore + def _get_skills_dir(self) -> Optional[Path]: + """Return the skills directory if ``--ai-skills`` was used during init. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled or the init-options file is missing. + """ + from . import load_init_options, _get_skills_dir + + opts = load_init_options(self.project_root) + if not opts.get("ai_skills"): + return None + + agent = opts.get("ai") + if not agent: + return None + + skills_dir = _get_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_extension_skills( + self, + manifest: ExtensionManifest, + extension_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for extension commands as agent skills. + + For every command in the extension manifest, creates a SKILL.md + file in the agent's skills directory following the agentskills.io + specification. This is only done when ``--ai-skills`` was used + during project initialisation. + + Args: + manifest: Extension manifest. + extension_dir: Installed extension directory. + + Returns: + List of skill names that were created (for registry storage). + """ + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import load_init_options + import yaml + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai", "") + + written: List[str] = [] + + for cmd_info in manifest.commands: + cmd_name = cmd_info["name"] + cmd_file_rel = cmd_info["file"] + source_file = extension_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Derive skill name from command name + # e.g. "speckit.jira.create" -> "speckit-jira-create" (or dot for kimi) + if selected_ai == "kimi": + skill_name = cmd_name # Keep dot notation for kimi + else: + skill_name = cmd_name.replace(".", "-") + + # Check if skill already exists before creating the directory + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if skill_file.exists(): + # Do not overwrite user-customized skills + continue + + # Create skill directory only when we're going to write to it + skill_subdir.mkdir(parents=True, exist_ok=True) + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + try: + frontmatter = yaml.safe_load(parts[1]) + except yaml.YAMLError: + frontmatter = {} + if not isinstance(frontmatter, dict): + frontmatter = {} + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + original_desc = frontmatter.get("description", "") + description = original_desc or f"Extension command: {cmd_name}" + + frontmatter_data = { + "name": skill_name, + "description": description, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": f"extension:{manifest.id}", + }, + } + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + + # Derive a human-friendly title from the command name + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {skill_name.replace('-', ' ').replace('.', ' ').title()} Skill\n\n" + f"{body}\n" + ) + + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_extension_skills(self, skill_names: List[str]) -> 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 ``_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. + + Args: + skill_names: List of skill names to remove. + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + + if skills_dir: + # Fast path: we know the exact skills directory + for skill_name in skill_names: + skill_subdir = skills_dir / skill_name + if skill_subdir.exists(): + shutil.rmtree(skill_subdir) + else: + # Fallback: scan all possible agent skills directories + from . import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + + candidate_dirs: set[Path] = set() + for override_path in AGENT_SKILLS_DIR_OVERRIDES.values(): + candidate_dirs.add(self.project_root / override_path) + for cfg in AGENT_CONFIG.values(): + folder = cfg.get("folder", "") + if folder: + candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills") + candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR) + + for skills_candidate in candidate_dirs: + if not skills_candidate.is_dir(): + continue + for skill_name in skill_names: + skill_subdir = skills_candidate / skill_name + if skill_subdir.exists(): + shutil.rmtree(skill_subdir) + def check_compatibility( self, manifest: ExtensionManifest, @@ -487,6 +666,10 @@ def install_from_directory( manifest, dest_dir, self.project_root ) + # Auto-register extension commands as agent skills when --ai-skills + # was used during project initialisation (feature parity). + registered_skills = self._register_extension_skills(manifest, dest_dir) + # Register hooks hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) @@ -497,7 +680,8 @@ def install_from_directory( "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, - "registered_commands": registered_commands + "registered_commands": registered_commands, + "registered_skills": registered_skills, }) return manifest @@ -569,9 +753,10 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if not self.registry.is_installed(extension_id): return False - # Get registered commands before removal + # Get registered commands and skills before removal metadata = self.registry.get(extension_id) registered_commands = metadata.get("registered_commands", {}) + registered_skills = metadata.get("registered_skills", []) extension_dir = self.extensions_dir / extension_id @@ -580,6 +765,9 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + # Unregister agent skills + self._unregister_extension_skills(registered_skills) + if keep_config: # Preserve config files, only remove non-config files if extension_dir.exists(): diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py new file mode 100644 index 000000000..f011c059f --- /dev/null +++ b/tests/test_extension_skills.py @@ -0,0 +1,640 @@ +""" +Unit tests for extension skill auto-registration. + +Tests cover: +- SKILL.md generation when --ai-skills was used during init +- No skills created when ai_skills not active +- SKILL.md content correctness +- Existing user-modified skills not overwritten +- Skill cleanup on extension removal +- Registry metadata includes registered_skills +""" + +import json +import pytest +import tempfile +import shutil +import yaml +from pathlib import Path + +from specify_cli.extensions import ( + ExtensionManifest, + ExtensionManager, + ExtensionError, +) + + +# ===== Helpers ===== + +def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True): + """Write a .specify/init-options.json file.""" + opts_dir = project_root / ".specify" + opts_dir.mkdir(parents=True, exist_ok=True) + opts_file = opts_dir / "init-options.json" + opts_file.write_text(json.dumps({ + "ai": ai, + "ai_skills": ai_skills, + "script": "sh", + })) + + +def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: + """Create and return the expected skills directory for the given agent.""" + # Match the logic in _get_skills_dir() from specify_cli + from specify_cli import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR + + if ai in AGENT_SKILLS_DIR_OVERRIDES: + skills_dir = project_root / AGENT_SKILLS_DIR_OVERRIDES[ai] + else: + agent_config = AGENT_CONFIG.get(ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + skills_dir = project_root / agent_folder.rstrip("/") / "skills" + else: + skills_dir = project_root / DEFAULT_SKILLS_DIR + + skills_dir.mkdir(parents=True, exist_ok=True) + return skills_dir + + +def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path: + """Create a complete extension directory with manifest and command files.""" + ext_dir = temp_dir / ext_id + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension for skill registration", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test hello command", + }, + { + "name": f"speckit.{ext_id}.world", + "file": "commands/world.md", + "description": "Test world command", + }, + ] + }, + } + + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + + (commands_dir / "hello.md").write_text( + "---\n" + "description: \"Test hello command\"\n" + "---\n" + "\n" + "# Hello Command\n" + "\n" + "Run this to say hello.\n" + "$ARGUMENTS\n" + ) + + (commands_dir / "world.md").write_text( + "---\n" + "description: \"Test world command\"\n" + "---\n" + "\n" + "# World Command\n" + "\n" + "Run this to greet the world.\n" + ) + + return ext_dir + + +# ===== Fixtures ===== + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + # Create .specify directory + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + return proj_dir + + +@pytest.fixture +def extension_dir(temp_dir): + """Create a complete extension directory.""" + return _create_extension_dir(temp_dir) + + +@pytest.fixture +def skills_project(project_dir): + """Create a project with --ai-skills enabled and skills directory.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + return project_dir, skills_dir + + +@pytest.fixture +def no_skills_project(project_dir): + """Create a project without --ai-skills.""" + _create_init_options(project_dir, ai="claude", ai_skills=False) + return project_dir + + +# ===== ExtensionManager._get_skills_dir Tests ===== + +class TestExtensionManagerGetSkillsDir: + """Test _get_skills_dir() on ExtensionManager.""" + + def test_returns_skills_dir_when_active(self, skills_project): + """Should return skills dir when ai_skills is true and dir exists.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result == skills_dir + + def test_returns_none_when_no_ai_skills(self, no_skills_project): + """Should return None when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_no_init_options(self, project_dir): + """Should return None when init-options.json is missing.""" + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_skills_dir_missing(self, project_dir): + """Should return None when skills dir doesn't exist on disk.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + # Don't create the skills directory + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + +# ===== Extension Skill Registration Tests ===== + +class TestExtensionSkillRegistration: + """Test _register_extension_skills() on ExtensionManager.""" + + def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir): + """Skills should be created when ai_skills is enabled.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Check that skill directories were created + skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) + assert "speckit-test-ext-hello" in skill_dirs + assert "speckit-test-ext-world" in skill_dirs + + def test_skill_md_content_correct(self, skills_project, extension_dir): + """SKILL.md should have correct agentskills.io structure.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Check structure + assert content.startswith("---\n") + assert "name: speckit-test-ext-hello" in content + assert "description:" in content + assert "Test hello command" in content + assert "source: extension:test-ext" in content + assert "author: github-spec-kit" in content + assert "compatibility:" in content + assert "Run this to say hello." in content + + def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): + """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + content = skill_file.read_text() + + assert content.startswith("---\n") + parts = content.split("---", 2) + assert len(parts) >= 3 + parsed = yaml.safe_load(parts[1]) + assert isinstance(parsed, dict) + assert parsed["name"] == "speckit-test-ext-hello" + assert "description" in parsed + + def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): + """No skills should be created when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify registry + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_no_skills_when_init_options_missing(self, project_dir, extension_dir): + """No skills should be created when init-options.json is absent.""" + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_existing_skill_not_overwritten(self, skills_project, extension_dir): + """Pre-existing SKILL.md should not be overwritten.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "speckit-test-ext-hello" + custom_dir.mkdir(parents=True) + custom_content = "# My Custom Hello Skill\nUser-modified content\n" + (custom_dir / "SKILL.md").write_text(custom_content) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Custom skill should be untouched + assert (custom_dir / "SKILL.md").read_text() == custom_content + + # But the other skill should still be created + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-world" in metadata["registered_skills"] + # The pre-existing one should NOT be in registered_skills (it was skipped) + assert "speckit-test-ext-hello" not in metadata["registered_skills"] + + def test_registered_skills_in_registry(self, skills_project, extension_dir): + """Registry should contain registered_skills list.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "registered_skills" in metadata + assert len(metadata["registered_skills"]) == 2 + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + + def test_kimi_uses_dot_notation(self, project_dir, temp_dir): + """Kimi agent should use dot notation for skill names.""" + _create_init_options(project_dir, ai="kimi", ai_skills=True) + _create_skills_dir(project_dir, ai="kimi") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + # Kimi should use dots, not hyphens + assert "speckit.test-ext.hello" in metadata["registered_skills"] + assert "speckit.test-ext.world" in metadata["registered_skills"] + + def test_missing_command_file_skipped(self, skills_project, temp_dir): + """Commands with missing source files should be skipped gracefully.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "missing-cmd-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "missing-cmd-ext", + "name": "Missing Cmd Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.missing-cmd-ext.exists", + "file": "commands/exists.md", + "description": "Exists", + }, + { + "name": "speckit.missing-cmd-ext.ghost", + "file": "commands/ghost.md", + "description": "Does not exist", + }, + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "exists.md").write_text( + "---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n" + ) + # Intentionally do NOT create ghost.md + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"] + assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"] + + +# ===== Extension Skill Unregistration Tests ===== + +class TestExtensionSkillUnregistration: + """Test _unregister_extension_skills() on ExtensionManager.""" + + def test_skills_removed_on_extension_remove(self, skills_project, extension_dir): + """Removing an extension should clean up its skill directories.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() + + # Remove extension + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + # Skills should be gone + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() + + def test_other_skills_preserved_on_remove(self, skills_project, extension_dir): + """Non-extension skills should not be affected by extension removal.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "my-custom-skill" + custom_dir.mkdir(parents=True) + (custom_dir / "SKILL.md").write_text("# My Custom Skill\n") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + manager.remove(manifest.id, keep_config=False) + + # Custom skill should still exist + assert (custom_dir / "SKILL.md").exists() + assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n" + + def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir): + """Gracefully handle case where skill dirs were already deleted.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Manually delete skill dirs before calling remove + shutil.rmtree(skills_dir / "speckit-test-ext-hello") + shutil.rmtree(skills_dir / "speckit-test-ext-world") + + # Should not raise + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir): + """Removal without active skills should not attempt skill cleanup.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Should not raise even though no skills exist + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + +# ===== Command File Without Frontmatter ===== + +class TestExtensionSkillEdgeCases: + """Test edge cases in extension skill registration.""" + + def test_command_without_frontmatter(self, skills_project, temp_dir): + """Commands without YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "nofm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "nofm-ext", + "name": "No Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.nofm-ext.plain", + "file": "commands/plain.md", + "description": "Plain command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "plain.md").write_text( + "# Plain Command\n\nBody without frontmatter.\n" + ) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: speckit-nofm-ext-plain" in content + # Fallback description when no frontmatter description + assert "Extension command: speckit.nofm-ext.plain" in content + assert "Body without frontmatter." in content + + def test_gemini_agent_skills(self, project_dir, temp_dir): + """Gemini agent should use .gemini/skills/ for skill directory.""" + _create_init_options(project_dir, ai="gemini", ai_skills=True) + _create_skills_dir(project_dir, ai="gemini") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skills_dir = project_dir / ".gemini" / "skills" + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() + + def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): + """Installing and removing different extensions should be independent.""" + project_dir, skills_dir = skills_project + + ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a") + ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b") + + manager = ExtensionManager(project_dir) + manifest_a = manager.install_from_directory( + ext_dir_a, "0.1.0", register_commands=False + ) + manifest_b = manager.install_from_directory( + ext_dir_b, "0.1.0", register_commands=False + ) + + # Both should have skills + assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() + + # Remove ext-a + manager.remove("ext-a", keep_config=False) + + # ext-a skills gone, ext-b skills preserved + assert not (skills_dir / "speckit-ext-a-hello").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() + + def test_malformed_frontmatter_handled(self, skills_project, temp_dir): + """Commands with invalid YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "badfm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "badfm-ext", + "name": "Bad Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.badfm-ext.broken", + "file": "commands/broken.md", + "description": "Broken frontmatter", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + # Malformed YAML: invalid key-value syntax + (ext_dir / "commands" / "broken.md").write_text( + "---\n" + "description: [invalid yaml\n" + " unclosed: bracket\n" + "---\n" + "\n" + "# Broken Command\n" + "\n" + "This body should still be used.\n" + ) + + manager = ExtensionManager(project_dir) + # Should not raise + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + # Fallback description since frontmatter was invalid + assert "Extension command: speckit.badfm-ext.broken" in content + assert "This body should still be used." in content + + def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir): + """Skills should be cleaned up even if init-options.json is deleted after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + # Delete init-options.json to simulate user change + init_opts = project_dir / ".specify" / "init-options.json" + init_opts.unlink() + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() + + def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir): + """Skills should be cleaned up even if ai_skills is toggled to false after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + # Toggle ai_skills to false + _create_init_options(project_dir, ai="claude", ai_skills=False) + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists()