feat: implement ai-skills command line switch#1600
feat: implement ai-skills command line switch#1600dhilipkumars wants to merge 9 commits intogithub:mainfrom
Conversation
|
@mnriem i re-worked on this a bit, i spent a little bit of time on this and realized each coding agent uses its own directory to store project specific skills, so sort of re-wrote it. PTAL. eg:
|
There was a problem hiding this comment.
Pull request overview
This PR implements a new --ai-skills command-line flag for the specify init command that installs Prompt.MD templates from templates/commands/ as agent skills following the agentskills.io specification. The feature creates SKILL.md files in agent-specific skills directories (e.g., .claude/skills/, .gemini/skills/) and optionally cleans up duplicate command files to prevent conflicts.
Changes:
- Added
install_ai_skills()function to convert 9 command templates into agent skills with enhanced descriptions - Added
_get_skills_dir()helper to resolve agent-specific skills directories with override support - Added validation requiring
--aiflag when using--ai-skills - Version bumped from 0.1.0 to 0.1.1
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| src/specify_cli/init.py | Core implementation: added yaml import, AGENT_SKILLS_DIR_OVERRIDES/SKILL_DESCRIPTIONS constants, install_ai_skills() and _get_skills_dir() functions, --ai-skills CLI option, validation logic, and tracker integration |
| pyproject.toml | Version bump from 0.1.0 to 0.1.1 |
| README.md | Added documentation for --ai-skills flag in options table and usage examples |
| CHANGELOG.md | Added 0.1.1 release notes documenting the new agent skills installation feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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 |
There was a problem hiding this comment.
The frontmatter parsing logic using split("---", 2) is less robust than the approach used in the extensions module. The extensions.py module at lines 683-708 uses content.find("---", 3) to locate the second delimiter, which is more precise. While split with maxsplit=2 will work in most cases, using find() to locate the exact position of the second delimiter is a more reliable approach. Consider refactoring to use the parse_frontmatter method from the CommandRegistrar class or implementing a similar approach.
| Installation is additive — existing files are never removed and prompt | ||
| command files in the agent's commands directory are left untouched. |
There was a problem hiding this comment.
The docstring claims that "prompt command files in the agent's commands directory are left untouched" (line 1034), but this is incorrect. The implementation at lines 1133-1154 explicitly removes command files matching the pattern "speckit.*" from the commands directory and may even delete the commands directory if it becomes empty. This is a direct contradiction. The docstring should be updated to accurately reflect that the function removes duplicate command files from the commands directory after installing skills.
| Installation is additive — existing files are never removed and prompt | |
| command files in the agent's commands directory are left untouched. | |
| Installation is additive with respect to skills — existing skills are not | |
| removed. After installing skills, duplicate prompt command files generated | |
| by this tool (matching ``speckit.*``) may be removed from the agent's | |
| commands directory, and the commands directory itself may be deleted if it | |
| becomes empty. User-created non-``speckit.*`` command files are left | |
| untouched. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 19 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 | ||
|
|
There was a problem hiding this comment.
The tests don't verify that generated SKILL.md files contain valid, parseable YAML frontmatter. Consider adding a test that reads back the generated SKILL.md and parses the YAML to ensure it's valid. This would catch issues with special characters in descriptions that could break YAML parsing.
| "Body without frontmatter.\n", | ||
| encoding="utf-8", | ||
| ) | ||
|
|
There was a problem hiding this comment.
Consider adding a test case for templates with empty YAML frontmatter (e.g., "---\n---\n# Content"). This would test the edge case where yaml.safe_load() returns None, which could cause issues in the current implementation.
| # Template with empty YAML frontmatter (safe_load() returns None) | |
| (tpl_root / "empty_frontmatter.md").write_text( | |
| "---\n" | |
| "---\n" | |
| "\n" | |
| "# Empty Frontmatter Command\n" | |
| "\n" | |
| "Body with empty frontmatter.\n", | |
| encoding="utf-8", | |
| ) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if content.startswith("---"): | ||
| parts = content.split("---", 2) | ||
| if len(parts) >= 3: | ||
| frontmatter = yaml.safe_load(parts[1]) or {} | ||
| body = parts[2].strip() | ||
| else: | ||
| frontmatter = {} | ||
| body = content | ||
| else: | ||
| frontmatter = {} | ||
| body = content |
There was a problem hiding this comment.
The YAML frontmatter parsing assumes content.split("---", 2) will always produce at least 3 parts when content starts with "---", but this may not be true if there's only one "---" in the file. While this is partially handled by checking len(parts) >= 3, the error handling could be improved. If a file starts with "---" but has no closing "---", it will be treated as having no frontmatter, which might be unexpected. Consider adding a warning or stricter validation to catch malformed frontmatter.
| 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} | ||
| """ | ||
|
|
There was a problem hiding this comment.
The skill_content f-string at lines 1110-1122 directly interpolates enhanced_desc into the YAML frontmatter without escaping. If enhanced_desc contains special YAML characters (like colons, quotes, or newlines), it could break the YAML structure. While SKILL_DESCRIPTIONS is a controlled constant, the fallback to original_desc (from user-provided template frontmatter) could contain problematic characters. Consider using yaml.dump() to safely serialize the description or adding validation/escaping to prevent YAML structure corruption.
| 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} | |
| """ | |
| enhanced_desc = SKILL_DESCRIPTIONS.get( | |
| command_name, | |
| original_desc or f"Spec-kit workflow command: {command_name}", | |
| ) | |
| # Build SKILL.md following agentskills.io spec | |
| frontmatter_data = { | |
| "name": skill_name, | |
| "description": enhanced_desc, | |
| "compatibility": "Requires spec-kit project structure with .specify/ directory", | |
| "metadata": { | |
| "author": "github-spec-kit", | |
| "source": f"templates/commands/{command_file.name}", | |
| }, | |
| } | |
| frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() | |
| skill_content = ( | |
| f"---\n" | |
| f"{frontmatter_text}\n" | |
| f"---\n\n" | |
| f"# Speckit {command_name.title()} Skill\n\n" | |
| f"{body}\n" | |
| ) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - **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/` following Codex agent directory conventions | ||
| - 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 | ||
| - **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 |
There was a problem hiding this comment.
The changelog claims --ai-skills installs skills for agents like Gemini and Copilot (e.g. .gemini/skills/, .github/skills/). In the current implementation, install_ai_skills() only searches <agent_folder>/commands/*.md, which won’t find Gemini’s generated .toml commands or Copilot’s .github/agents layout (per the release packaging script). Either broaden the implementation to cover those agent layouts/formats, or narrow these CHANGELOG bullets to match what’s actually supported.
| agent_folder = agent_config.get("folder", "") | ||
| if agent_folder: | ||
| templates_dir = project_path / agent_folder.rstrip("/") / "commands" | ||
| else: | ||
| templates_dir = project_path / "commands" |
There was a problem hiding this comment.
install_ai_skills() assumes extracted command templates live under <agent_folder>/commands (or commands/). However the release packaging script generates agent command files in different directories for several agents (e.g. .github/agents, .opencode/command, .codex/prompts, .windsurf/workflows, .amazonq/prompts). With the current path logic, --ai-skills will fail to find any templates for those agents. Consider introducing an explicit per-agent mapping for the extracted command directory and using that here.
| agent_folder = agent_config.get("folder", "") | |
| if agent_folder: | |
| templates_dir = project_path / agent_folder.rstrip("/") / "commands" | |
| else: | |
| templates_dir = project_path / "commands" | |
| # Some agents store their command templates in non-standard locations. | |
| # Provide explicit per-agent overrides keyed by the same value used in | |
| # AGENT_CONFIG (i.e. ``selected_ai``). | |
| command_dir_overrides: dict[str, str] = { | |
| # These paths are relative to ``project_path`` and should point | |
| # directly to the directory containing the *.md command files. | |
| # The keys here should match the agent keys used with AGENT_CONFIG. | |
| "github": ".github/agents", | |
| "opencode": ".opencode/command", | |
| "codex": ".codex/prompts", | |
| "windsurf": ".windsurf/workflows", | |
| "amazonq": ".amazonq/prompts", | |
| } | |
| override_rel = command_dir_overrides.get(selected_ai) | |
| if override_rel: | |
| # Use the explicitly configured commands directory for this agent. | |
| templates_dir = project_path / override_rel | |
| else: | |
| # Fall back to the folder defined in AGENT_CONFIG, if any. | |
| agent_folder = agent_config.get("folder", "").rstrip("/") | |
| if agent_folder: | |
| # If the folder already looks like a concrete commands directory | |
| # (e.g. ".../commands", ".../command", ".../prompts", | |
| # ".../workflows"), use it as-is instead of appending "commands". | |
| if agent_folder.endswith(("commands", "command", "prompts", "workflows")): | |
| templates_dir = project_path / agent_folder | |
| else: | |
| templates_dir = project_path / agent_folder / "commands" | |
| else: | |
| # Default to a top-level "commands" directory. | |
| templates_dir = project_path / "commands" |
| 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 |
There was a problem hiding this comment.
command_files = templates_dir.glob("*.md") means agents whose extracted commands are not Markdown (e.g. Gemini/Qwen generate speckit.<cmd>.toml in the release packages) will always report “no command templates found” and skip skills installation. This should either (a) support additional extensions and parse the agent-specific format to extract the prompt, or (b) point to a shared Markdown source included in the extracted project.
| 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}") | ||
|
|
There was a problem hiding this comment.
command_name = command_file.stem will produce values like speckit.specify for extracted command files named speckit.<cmd>.md (as generated by the release packaging script). That makes skill_name become speckit-speckit.specify and prevents SKILL_DESCRIPTIONS lookups (keys are specify, plan, etc.). Normalize the command name (e.g. strip a leading speckit. and replace . with - as needed) before generating skill_name and looking up descriptions.
| if content.startswith("---"): | ||
| parts = content.split("---", 2) | ||
| if len(parts) >= 3: | ||
| frontmatter = yaml.safe_load(parts[1]) or {} | ||
| body = parts[2].strip() | ||
| else: | ||
| # File starts with --- but has no closing --- | ||
| console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") | ||
| 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}") |
There was a problem hiding this comment.
yaml.safe_load(parts[1]) can validly return a non-mapping type (e.g. a list/string) even when the YAML is syntactically valid; in that case frontmatter.get(...) will raise and the skill will be skipped. Coerce to {} unless the parsed frontmatter is a dict (and consider warning when it isn’t).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if content.startswith("---"): | ||
| parts = content.split("---", 2) | ||
| if len(parts) >= 3: | ||
| frontmatter = yaml.safe_load(parts[1]) | ||
| if not isinstance(frontmatter, dict): | ||
| frontmatter = {} | ||
| body = parts[2].strip() | ||
| else: | ||
| # File starts with --- but has no closing --- | ||
| console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") | ||
| frontmatter = {} | ||
| body = content | ||
| else: | ||
| frontmatter = {} | ||
| body = content |
There was a problem hiding this comment.
body = parts[2].strip() will remove leading/trailing whitespace from the template body, which can change markdown semantics (e.g., intentional leading blank lines, trailing newlines, or whitespace-sensitive blocks). To preserve template fidelity, avoid full strip() here (e.g., only remove a single leading newline after the closing frontmatter, or don’t trim at all).
| def test_non_md_commands_dir_falls_back(self, project_dir): | ||
| """When extracted commands are .toml (e.g. gemini), fall back to repo templates.""" | ||
| # Simulate gemini template extraction: .gemini/commands/ with .toml files only | ||
| cmds_dir = project_dir / ".gemini" / "commands" | ||
| cmds_dir.mkdir(parents=True) | ||
| (cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n') | ||
| (cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n') | ||
|
|
||
| # The __file__ fallback should find the real repo templates/commands/*.md | ||
| result = install_ai_skills(project_dir, "gemini") | ||
|
|
||
| assert result is True | ||
| skills_dir = project_dir / ".gemini" / "skills" | ||
| assert skills_dir.exists() | ||
| # Should have installed skills from the fallback .md templates | ||
| skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] | ||
| assert len(skill_dirs) >= 1 | ||
| # .toml commands should be untouched | ||
| assert (cmds_dir / "speckit.specify.toml").exists() | ||
|
|
There was a problem hiding this comment.
This test relies on the implementation’s __file__-relative fallback to templates/commands/*.md. In a packaged install those templates aren’t included in the wheel (only src/specify_cli is), so this scenario can pass in-repo but still fail for end users (especially gemini where extracted commands are .toml). Consider updating the implementation (and the test) to fall back to templates located in the extracted project (e.g., .specify/templates/commands) or to packaged resources.
| # Fallback: try the repo-relative path (for running from source checkout) | ||
| # This also covers agents whose extracted commands are in a different | ||
| # format (e.g. gemini uses .toml, not .md). | ||
| script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ | ||
| fallback_dir = script_dir / "templates" / "commands" | ||
| if fallback_dir.exists() and any(fallback_dir.glob("*.md")): | ||
| templates_dir = fallback_dir |
There was a problem hiding this comment.
The fallback lookup for templates uses a path derived from __file__ (i.e., the installed package location). In the wheel configuration this repo’s templates/commands directory is not packaged, so this fallback will fail in real installs (notably for agents like gemini where the extracted commands may be .toml and no *.md exist). Consider instead locating templates inside the extracted project (e.g., .specify/templates/commands/*.md if present) or shipping templates as package data and loading via importlib.resources.
| # Fallback: try the repo-relative path (for running from source checkout) | |
| # This also covers agents whose extracted commands are in a different | |
| # format (e.g. gemini uses .toml, not .md). | |
| script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ | |
| fallback_dir = script_dir / "templates" / "commands" | |
| if fallback_dir.exists() and any(fallback_dir.glob("*.md")): | |
| templates_dir = fallback_dir | |
| # Fallback: look for project-local templates inside the extracted project. | |
| # This avoids relying on package layout (e.g. wheel not shipping templates). | |
| specify_templates_dir = project_path / ".specify" / "templates" / "commands" | |
| if specify_templates_dir.exists() and any(specify_templates_dir.glob("*.md")): | |
| templates_dir = specify_templates_dir |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@mnriem does the PR look good to you now? |
|
Fixes #1585 |
Implements agent skills with a command line switch
--ai-skills, needs to be used in combination with--ai.Additional help text when you
specify init --helpSample run
will result in
ai coding agent: anti-gravity
Fixes #1585 , #1615