From 5483089ea90c125d7fcf4bf8efcf2965259c35d3 Mon Sep 17 00:00:00 2001 From: dhilipkumars Date: Fri, 13 Feb 2026 10:24:37 -0500 Subject: [PATCH 1/2] implement ai-skills command line switch --- CHANGELOG.md | 13 +++ README.md | 7 ++ pyproject.toml | 2 +- src/specify_cli/__init__.py | 189 ++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174b429cbc..716ccd9d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2026-02-13 + +### Added + +- **Agent Skills Installation**: New `--ai-skills` CLI option to install Prompt.MD templates as agent skills following [agentskills.io specification](https://agentskills.io/specification) + - Skills are installed to agent-specific directories (e.g., `.claude/skills/`, `.gemini/skills/`, `.github/skills/`) + - Codex uses `.agents/skills/` per [OpenAI Codex docs](https://developers.openai.com/codex/skills) + - Default fallback directory is `.agents/skills/` for agents without a specific mapping + - Requires `--ai` flag to be specified + - Converts all 9 spec-kit command templates (specify, plan, tasks, implement, analyze, clarify, constitution, checklist, taskstoissues) to properly formatted SKILL.md files + - Additive only: does not duplicate or interfere with existing prompt commands + - `pyyaml` dependency (already present) used for YAML frontmatter parsing + ## [0.1.0] - 2026-01-28 ### Added diff --git a/README.md b/README.md index e189750f28..f55d16238a 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,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`) | ### Examples @@ -239,6 +240,12 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here +# Install agent skills with the project +specify init my-project --ai claude --ai-skills + +# Initialize in current directory with agent skills +specify init --here --ai gemini --ai-skills + # Check system requirements specify check ``` diff --git a/pyproject.toml b/pyproject.toml index de6fe5fe9a..7ca679cb08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.1.0" +version = "0.1.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 70c5bd27c5..1f18cd9d8e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -32,6 +32,7 @@ import shutil import shlex import json +import yaml from pathlib import Path from typing import Optional, Tuple @@ -983,6 +984,178 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") +# Agent-specific skill directory overrides for agents whose skills directory +# doesn't follow the standard /skills/ pattern +AGENT_SKILLS_DIR_OVERRIDES = { + "codex": ".agents/skills", # per https://developers.openai.com/codex/skills +} + +# Default skills directory for agents not in AGENT_CONFIG +DEFAULT_SKILLS_DIR = ".agents/skills" + +# Enhanced descriptions for each spec-kit command skill +SKILL_DESCRIPTIONS = { + "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", + "plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.", + "tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.", + "implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.", + "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.", + "clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.", + "constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.", + "checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.", + "taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.", +} + + +def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: + """Resolve the agent-specific skills directory for the given AI assistant. + + Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to + ``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to + ``DEFAULT_SKILLS_DIR``. + """ + if selected_ai in AGENT_SKILLS_DIR_OVERRIDES: + return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai] + + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + return project_path / agent_folder.rstrip("/") / "skills" + + return project_path / DEFAULT_SKILLS_DIR + + +def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool: + """Install Prompt.MD files from templates/commands/ as agent skills. + + Skills are written to the agent-specific skills directory following the + `agentskills.io `_ specification. + Installation is additive — existing files are never removed and prompt + command files in the agent's commands directory are left untouched. + + Args: + project_path: Target project directory. + selected_ai: AI assistant key from ``AGENT_CONFIG``. + tracker: Optional progress tracker. + + Returns: + ``True`` if at least one skill was installed, ``False`` otherwise. + """ + # Locate the bundled templates directory + script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ + templates_dir = script_dir / "templates" / "commands" + + if not templates_dir.exists(): + if tracker: + tracker.error("ai-skills", "templates/commands not found") + else: + console.print("[yellow]Warning: templates/commands directory not found, skipping skills installation[/yellow]") + return False + + command_files = sorted(templates_dir.glob("*.md")) + if not command_files: + if tracker: + tracker.skip("ai-skills", "no command templates found") + else: + console.print("[yellow]No command templates found to install[/yellow]") + return False + + # Resolve the correct skills directory for this agent + skills_dir = _get_skills_dir(project_path, selected_ai) + skills_dir.mkdir(parents=True, exist_ok=True) + + if tracker: + tracker.start("ai-skills") + + installed_count = 0 + for command_file in command_files: + try: + content = command_file.read_text(encoding="utf-8") + + # Parse YAML frontmatter + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) + body = parts[2].strip() + else: + frontmatter = {} + body = content + else: + frontmatter = {} + body = content + + command_name = command_file.stem + skill_name = f"speckit-{command_name}" + + # Create skill directory (additive — never removes existing content) + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + + # Select the best description available + original_desc = frontmatter.get("description", "") if frontmatter else "" + enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}") + + # Build SKILL.md following agentskills.io spec + skill_content = f"""--- +name: {skill_name} +description: {enhanced_desc} +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + author: github-spec-kit + source: templates/commands/{command_file.name} +--- + +# Speckit {command_name.title()} Skill + +{body} +""" + + skill_file = skill_dir / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + installed_count += 1 + + except Exception as e: + console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]") + continue + + if tracker: + if installed_count > 0: + tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}") + else: + tracker.error("ai-skills", "no skills installed") + else: + if installed_count > 0: + console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/") + else: + console.print("[yellow]No skills were installed[/yellow]") + + # When skills are installed, remove the duplicate command files that were + # extracted from the template archive. This prevents the agent from + # seeing both /commands and /skills for the same functionality. + if installed_count > 0: + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + commands_dir = project_path / agent_folder.rstrip("/") / "commands" + if commands_dir.exists(): + removed = 0 + for cmd_file in list(commands_dir.glob("speckit.*")): + try: + cmd_file.unlink() + removed += 1 + except OSError: + pass + # Remove the commands directory if it is now empty + try: + if commands_dir.exists() and not any(commands_dir.iterdir()): + commands_dir.rmdir() + except OSError: + pass + + return installed_count > 0 + + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -995,6 +1168,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), ): """ Initialize a new Specify project from the latest template. @@ -1019,6 +1193,8 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --ai claude --ai-skills # Install agent skills + specify init --here --ai gemini --ai-skills """ show_banner() @@ -1035,6 +1211,11 @@ def init( console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) + if ai_skills and not ai_assistant: + console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") + raise typer.Exit(1) + if here: project_name = Path.cwd().name project_path = Path.cwd() @@ -1150,6 +1331,11 @@ def init( ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), + ]: + tracker.add(key, label) + if ai_skills: + tracker.add("ai-skills", "Install agent skills") + for key, label in [ ("cleanup", "Cleanup"), ("git", "Initialize git repository"), ("final", "Finalize") @@ -1172,6 +1358,9 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + if ai_skills: + install_ai_skills(project_path, selected_ai, tracker=tracker) + if not no_git: tracker.start("git") if is_git_repo(project_path): From bcd134fe1259d139461fab31add4a9827595fc5e Mon Sep 17 00:00:00 2001 From: dhilipkumars Date: Fri, 13 Feb 2026 11:28:14 -0500 Subject: [PATCH 2/2] fix: address review comments, remove breaking change for existing projects, add tests --- CHANGELOG.md | 4 +- src/specify_cli/__init__.py | 36 +-- tests/test_ai_skills.py | 536 ++++++++++++++++++++++++++++++++++++ 3 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 tests/test_ai_skills.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 716ccd9d8f..59a9aab2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Default fallback directory is `.agents/skills/` for agents without a specific mapping - Requires `--ai` flag to be specified - Converts all 9 spec-kit command templates (specify, plan, tasks, implement, analyze, clarify, constitution, checklist, taskstoissues) to properly formatted SKILL.md files - - Additive only: does not duplicate or interfere with existing prompt commands + - **New projects**: command files are not installed when `--ai-skills` is used (skills replace commands) + - **Existing repos** (`--here`): pre-existing command files are preserved — no breaking changes - `pyyaml` dependency (already present) used for YAML frontmatter parsing +- **Unit tests** for `install_ai_skills`, `_get_skills_dir`, and `--ai-skills` CLI validation (26 test cases) ## [0.1.0] - 2026-01-28 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1f18cd9d8e..fd252fd727 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1130,29 +1130,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker else: console.print("[yellow]No skills were installed[/yellow]") - # When skills are installed, remove the duplicate command files that were - # extracted from the template archive. This prevents the agent from - # seeing both /commands and /skills for the same functionality. - if installed_count > 0: - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - if agent_folder: - commands_dir = project_path / agent_folder.rstrip("/") / "commands" - if commands_dir.exists(): - removed = 0 - for cmd_file in list(commands_dir.glob("speckit.*")): - try: - cmd_file.unlink() - removed += 1 - except OSError: - pass - # Remove the commands directory if it is now empty - try: - if commands_dir.exists() and not any(commands_dir.iterdir()): - commands_dir.rmdir() - except OSError: - pass - return installed_count > 0 @@ -1354,6 +1331,19 @@ def init( download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + # When --ai-skills is used on a NEW project, remove the command + # files that the template archive just created. Skills replace + # commands, so keeping both would be confusing. For --here on an + # existing repo we leave pre-existing commands untouched to avoid + # a breaking change. + if ai_skills and not here: + agent_cfg = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_cfg.get("folder", "") + if agent_folder: + cmds_dir = project_path / agent_folder.rstrip("/") / "commands" + if cmds_dir.exists(): + shutil.rmtree(cmds_dir) + ensure_executable_scripts(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py new file mode 100644 index 0000000000..76082da807 --- /dev/null +++ b/tests/test_ai_skills.py @@ -0,0 +1,536 @@ +""" +Unit tests for AI agent skills installation. + +Tests cover: +- Skills directory resolution for different agents (_get_skills_dir) +- YAML frontmatter parsing and SKILL.md generation (install_ai_skills) +- Cleanup of duplicate command files when --ai-skills is used +- Missing templates directory handling +- Malformed template error handling +- CLI validation: --ai-skills requires --ai +""" + +import pytest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch + +from specify_cli import ( + _get_skills_dir, + install_ai_skills, + AGENT_SKILLS_DIR_OVERRIDES, + DEFAULT_SKILLS_DIR, + SKILL_DESCRIPTIONS, + AGENT_CONFIG, + app, +) + + +# ===== 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 project directory.""" + proj_dir = temp_dir / "test-project" + proj_dir.mkdir() + return proj_dir + + +@pytest.fixture +def templates_dir(temp_dir): + """Create a mock templates/commands directory with sample templates.""" + tpl_root = temp_dir / "templates" / "commands" + tpl_root.mkdir(parents=True) + + # Template with valid YAML frontmatter + (tpl_root / "specify.md").write_text( + "---\n" + "description: Create or update the feature specification.\n" + "handoffs:\n" + " - label: Build Plan\n" + " agent: speckit.plan\n" + "scripts:\n" + " sh: scripts/bash/create-new-feature.sh\n" + "---\n" + "\n" + "# Specify Command\n" + "\n" + "Run this to create a spec.\n", + encoding="utf-8", + ) + + # Template with minimal frontmatter + (tpl_root / "plan.md").write_text( + "---\n" + "description: Generate implementation plan.\n" + "---\n" + "\n" + "# Plan Command\n" + "\n" + "Plan body content.\n", + encoding="utf-8", + ) + + # Template with no frontmatter + (tpl_root / "tasks.md").write_text( + "# Tasks Command\n" + "\n" + "Body without frontmatter.\n", + encoding="utf-8", + ) + + return tpl_root + + +@pytest.fixture +def commands_dir_claude(project_dir): + """Create a populated .claude/commands directory simulating template extraction.""" + cmd_dir = project_dir / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: + (cmd_dir / name).write_text(f"# {name}\nContent here\n") + return cmd_dir + + +@pytest.fixture +def commands_dir_gemini(project_dir): + """Create a populated .gemini/commands directory (TOML format).""" + cmd_dir = project_dir / ".gemini" / "commands" + cmd_dir.mkdir(parents=True) + for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]: + (cmd_dir / name).write_text(f'[command]\nname = "{name}"\n') + return cmd_dir + + +# ===== _get_skills_dir Tests ===== + +class TestGetSkillsDir: + """Test agent-specific skills directory resolution.""" + + def test_claude_skills_dir(self, project_dir): + """Claude skills go to .claude/skills.""" + result = _get_skills_dir(project_dir, "claude") + assert result == project_dir / ".claude" / "skills" + + def test_gemini_skills_dir(self, project_dir): + """Gemini skills go to .gemini/skills.""" + result = _get_skills_dir(project_dir, "gemini") + assert result == project_dir / ".gemini" / "skills" + + def test_copilot_skills_dir(self, project_dir): + """Copilot skills go to .github/skills.""" + result = _get_skills_dir(project_dir, "copilot") + assert result == project_dir / ".github" / "skills" + + def test_codex_uses_override(self, project_dir): + """Codex should use the AGENT_SKILLS_DIR_OVERRIDES mapping (.agents/skills).""" + result = _get_skills_dir(project_dir, "codex") + assert result == project_dir / ".agents" / "skills" + # Verify it's coming from the override, not AGENT_CONFIG + assert "codex" in AGENT_SKILLS_DIR_OVERRIDES + + def test_cursor_agent_skills_dir(self, project_dir): + """Cursor agent skills go to .cursor/skills.""" + result = _get_skills_dir(project_dir, "cursor-agent") + assert result == project_dir / ".cursor" / "skills" + + def test_unknown_agent_uses_default(self, project_dir): + """Unknown agents fall back to DEFAULT_SKILLS_DIR (.agents/skills).""" + result = _get_skills_dir(project_dir, "totally-unknown-agent") + assert result == project_dir / DEFAULT_SKILLS_DIR + + def test_all_configured_agents_resolve(self, project_dir): + """Every agent in AGENT_CONFIG should resolve to a valid path.""" + for agent_key in AGENT_CONFIG: + result = _get_skills_dir(project_dir, agent_key) + assert result is not None + assert str(result).startswith(str(project_dir)) + # Should always end with "skills" + assert result.name == "skills" + + def test_override_takes_precedence_over_config(self, project_dir): + """AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG.""" + for agent_key in AGENT_SKILLS_DIR_OVERRIDES: + result = _get_skills_dir(project_dir, agent_key) + expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key] + assert result == expected + + +# ===== install_ai_skills Tests ===== + +class TestInstallAiSkills: + """Test SKILL.md generation and installation logic.""" + + def test_skills_installed_with_correct_structure(self, project_dir, templates_dir): + """Verify SKILL.md files have correct agentskills.io structure.""" + # Directly call install_ai_skills with a patched templates dir path + import specify_cli + + orig_file = specify_cli.__file__ + # We need to make Path(__file__).parent.parent.parent resolve to temp root + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + assert result is True + + skills_dir = project_dir / ".claude" / "skills" + assert skills_dir.exists() + + # Check that skill directories were created + skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) + assert skill_dirs == ["speckit-plan", "speckit-specify", "speckit-tasks"] + + # Verify SKILL.md content for speckit-specify + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Check agentskills.io frontmatter + assert content.startswith("---\n") + assert "name: speckit-specify" in content + assert "description:" in content + assert "compatibility:" in content + assert "metadata:" in content + assert "author: github-spec-kit" in content + assert "source: templates/commands/specify.md" in content + + # Check body content is included + assert "# Speckit Specify Skill" in content + assert "Run this to create a spec." in content + + def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir): + """SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + + # Should use the enhanced description from SKILL_DESCRIPTIONS, not the template one + if "specify" in SKILL_DESCRIPTIONS: + assert SKILL_DESCRIPTIONS["specify"] in content + + def test_template_without_frontmatter(self, project_dir, templates_dir): + """Templates without YAML frontmatter should still produce valid skills.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "claude") + + skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Should still have valid SKILL.md structure + assert "name: speckit-tasks" in content + assert "Body without frontmatter." in content + + def test_missing_templates_directory(self, project_dir): + """Returns False when templates/commands directory doesn't exist.""" + import specify_cli + + # Point to a non-existent directory + fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + assert result is False + + # Skills directory should not exist + skills_dir = project_dir / ".claude" / "skills" + assert not skills_dir.exists() + + def test_empty_templates_directory(self, project_dir, temp_dir): + """Returns False when templates/commands has no .md files.""" + import specify_cli + + # Create empty templates/commands + empty_tpl = temp_dir / "empty_root" / "templates" / "commands" + empty_tpl.mkdir(parents=True) + fake_init = temp_dir / "empty_root" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + assert result is False + + def test_malformed_yaml_frontmatter(self, project_dir, temp_dir): + """Malformed YAML in a template should be handled gracefully, not crash.""" + import specify_cli + + tpl_dir = temp_dir / "bad_root" / "templates" / "commands" + tpl_dir.mkdir(parents=True) + + # Write a template with invalid YAML + (tpl_dir / "broken.md").write_text( + "---\n" + "description: [unclosed bracket\n" + " invalid: yaml: content: here\n" + "---\n" + "\n" + "# Broken\n", + encoding="utf-8", + ) + + fake_init = temp_dir / "bad_root" / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + # Should not raise — errors are caught per-file + result = install_ai_skills(project_dir, "claude") + + # The broken template should be skipped but not crash the process + assert result is False + + def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir): + """Installing skills should not remove non-speckit files in the skills dir.""" + import specify_cli + + # Pre-create a custom skill + custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill" + custom_dir.mkdir(parents=True) + custom_file = custom_dir / "SKILL.md" + custom_file.write_text("# My Custom Skill\n") + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "claude") + + # Custom skill should still exist + assert custom_file.exists() + assert custom_file.read_text() == "# My Custom Skill\n" + + def test_return_value(self, project_dir, templates_dir): + """install_ai_skills returns True when skills installed, False otherwise.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + assert install_ai_skills(project_dir, "claude") is True + + # Second call to non-existent dir + fake_init2 = project_dir / "missing" / "src" / "specify_cli" / "__init__.py" + fake_init2.parent.mkdir(parents=True, exist_ok=True) + fake_init2.touch() + + with patch.object(specify_cli, "__file__", str(fake_init2)): + assert install_ai_skills(project_dir, "claude") is False + + +# ===== Command Coexistence Tests ===== + +class TestCommandCoexistence: + """Verify install_ai_skills never touches command files. + + Cleanup of freshly-extracted commands for NEW projects is handled + in init(), not in install_ai_skills(). These tests confirm that + install_ai_skills leaves existing commands intact. + """ + + def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): + """install_ai_skills must NOT remove pre-existing .claude/commands files.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + # Verify commands exist before + assert len(list(commands_dir_claude.glob("speckit.*"))) == 3 + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "claude") + + # Commands must still be there — install_ai_skills never touches them + remaining = list(commands_dir_claude.glob("speckit.*")) + assert len(remaining) == 3 + + def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini): + """install_ai_skills must NOT remove pre-existing .gemini/commands files.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3 + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "gemini") + + remaining = list(commands_dir_gemini.glob("speckit.*")) + assert len(remaining) == 3 + + def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): + """install_ai_skills must not remove the commands directory.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + with patch.object(specify_cli, "__file__", str(fake_init)): + install_ai_skills(project_dir, "claude") + + assert commands_dir_claude.exists() + + def test_no_commands_dir_no_error(self, project_dir, templates_dir): + """No error when agent has no commands directory at all.""" + import specify_cli + + fake_init = templates_dir.parent.parent / "src" / "specify_cli" / "__init__.py" + fake_init.parent.mkdir(parents=True, exist_ok=True) + fake_init.touch() + + # No .claude/commands directory exists + with patch.object(specify_cli, "__file__", str(fake_init)): + result = install_ai_skills(project_dir, "claude") + + # Should still succeed + assert result is True + + +# ===== New-Project Command Skip Tests ===== + +class TestNewProjectCommandSkip: + """Test that init() removes extracted commands for new projects only. + + The init() function removes the freshly-extracted commands directory + when --ai-skills is used and the project is NEW (not --here). + For --here on existing repos, commands are left untouched. + """ + + def test_new_project_commands_dir_removed(self, project_dir): + """For new projects, the extracted commands directory should be removed.""" + import shutil as _shutil + + # Simulate what init() does: after extraction, if ai_skills and not here + agent_folder = AGENT_CONFIG["claude"]["folder"] + cmds_dir = project_dir / agent_folder.rstrip("/") / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text("# spec") + + ai_skills = True + here = False + + # Replicate the init() logic + if ai_skills and not here: + agent_cfg = AGENT_CONFIG.get("claude", {}) + af = agent_cfg.get("folder", "") + if af: + d = project_dir / af.rstrip("/") / "commands" + if d.exists(): + _shutil.rmtree(d) + + assert not cmds_dir.exists() + + def test_here_mode_commands_preserved(self, project_dir): + """For --here on existing repos, commands must NOT be removed.""" + agent_folder = AGENT_CONFIG["claude"]["folder"] + cmds_dir = project_dir / agent_folder.rstrip("/") / "commands" + cmds_dir.mkdir(parents=True) + (cmds_dir / "speckit.specify.md").write_text("# spec") + + ai_skills = True + here = True + + # Replicate the init() logic + if ai_skills and not here: + import shutil as _shutil + agent_cfg = AGENT_CONFIG.get("claude", {}) + af = agent_cfg.get("folder", "") + if af: + d = project_dir / af.rstrip("/") / "commands" + if d.exists(): + _shutil.rmtree(d) + + # Commands must remain for --here + assert cmds_dir.exists() + assert (cmds_dir / "speckit.specify.md").exists() + + +# ===== SKILL_DESCRIPTIONS Coverage Tests ===== + +class TestSkillDescriptions: + """Test SKILL_DESCRIPTIONS constants.""" + + def test_all_known_commands_have_descriptions(self): + """All standard spec-kit commands should have enhanced descriptions.""" + expected_commands = [ + "specify", "plan", "tasks", "implement", "analyze", + "clarify", "constitution", "checklist", "taskstoissues", + ] + for cmd in expected_commands: + assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'" + assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short" + + +# ===== CLI Validation Tests ===== + +class TestCliValidation: + """Test --ai-skills CLI flag validation.""" + + def test_ai_skills_without_ai_fails(self): + """--ai-skills without --ai should fail with exit code 1.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + + assert result.exit_code == 1 + assert "--ai-skills requires --ai" in result.output + + def test_ai_skills_without_ai_shows_usage(self): + """Error message should include usage hint.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + + assert "Usage:" in result.output + assert "--ai" in result.output + + def test_ai_skills_flag_appears_in_help(self): + """--ai-skills should appear in init --help output.""" + from typer.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(app, ["init", "--help"]) + + assert "--ai-skills" in result.output + assert "agent skills" in result.output.lower()