Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,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. |
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |

### 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 @@ -3594,6 +3594,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 []
Comment thread
dhilipkumars marked this conversation as resolved.
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 @@ -3632,14 +3638,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
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

# 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
205 changes: 203 additions & 2 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,198 @@ 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
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

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"]

# Guard against path traversal: reject absolute paths and ensure
# the resolved file stays within the extension directory.
cmd_path = Path(cmd_file_rel)
if cmd_path.is_absolute():
continue
try:
ext_root = extension_dir.resolve()
source_file = (ext_root / cmd_path).resolve()
source_file.relative_to(ext_root) # raises ValueError if outside
except (OSError, ValueError):
continue

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(".", "-")
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

# 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("---"):
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated
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."):]
title_name = short_name.replace(".", " ").replace("-", " ").title()

skill_content = (
f"---\n"
f"{frontmatter_text}\n"
f"---\n\n"
f"# {title_name} 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():
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated
shutil.rmtree(skill_subdir)
else:
Comment thread
dhilipkumars marked this conversation as resolved.
# 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)
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

def check_compatibility(
self,
manifest: ExtensionManifest,
Expand Down Expand Up @@ -601,6 +793,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 @@ -612,7 +808,8 @@ def install_from_directory(
"manifest_hash": manifest.get_hash(),
"enabled": True,
"priority": priority,
"registered_commands": registered_commands
"registered_commands": registered_commands,
"registered_skills": registered_skills,
})

return manifest
Expand Down Expand Up @@ -690,9 +887,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", {}) if metadata else {}
registered_skills = metadata.get("registered_skills", []) if metadata else []

extension_dir = self.extensions_dir / extension_id

Expand All @@ -701,6 +899,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)

Comment thread
dhilipkumars marked this conversation as resolved.
if keep_config:
# Preserve config files, only remove non-config files
if extension_dir.exists():
Expand Down
Loading
Loading