Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions extensions/EXTENSION-USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}/")

Expand Down Expand Up @@ -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)")
Expand Down
192 changes: 190 additions & 2 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +524 to +529
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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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():
Expand Down
Loading
Loading