Skip to content

Commit 7ee6858

Browse files
committed
feat: Auto-register ai-skills for extensions whenever applicable
1 parent f92d81b commit 7ee6858

File tree

5 files changed

+857
-4
lines changed

5 files changed

+857
-4
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ The `specify` command supports the following options:
212212
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
213213
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
214214
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
215-
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
215+
| `--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. |
216216

217217
### Examples
218218

extensions/EXTENSION-USER-GUIDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,21 @@ Provided commands:
187187
Check: .specify/extensions/jira/
188188
```
189189

190+
### Automatic Agent Skill Registration
191+
192+
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.
193+
194+
```text
195+
✓ Extension installed successfully!
196+
197+
Jira Integration (v1.0.0)
198+
...
199+
200+
✓ 3 agent skill(s) auto-registered
201+
```
202+
203+
When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten.
204+
190205
---
191206

192207
## Using Extensions

src/specify_cli/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2884,6 +2884,12 @@ def extension_add(
28842884
for cmd in manifest.commands:
28852885
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
28862886

2887+
# Report agent skills registration
2888+
reg_meta = manager.registry.get(manifest.id)
2889+
reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
2890+
if reg_skills:
2891+
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
2892+
28872893
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
28882894
console.print(f" Check: .specify/extensions/{manifest.id}/")
28892895

@@ -2922,14 +2928,18 @@ def extension_remove(
29222928
installed = manager.list_installed()
29232929
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
29242930

2925-
# Get extension info for command count
2931+
# Get extension info for command and skill counts
29262932
ext_manifest = manager.get_extension(extension_id)
29272933
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
2934+
reg_meta = manager.registry.get(extension_id)
2935+
skill_count = len(reg_meta.get("registered_skills", [])) if reg_meta else 0
29282936

29292937
# Confirm removal
29302938
if not force:
29312939
console.print("\n[yellow]⚠ This will remove:[/yellow]")
29322940
console.print(f" • {cmd_count} commands from AI agent")
2941+
if skill_count:
2942+
console.print(f" • {skill_count} agent skill(s)")
29332943
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
29342944
if not keep_config:
29352945
console.print(" • Config files (will be backed up)")

src/specify_cli/extensions.py

Lines changed: 190 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,185 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:
402402

403403
return _ignore
404404

405+
def _get_skills_dir(self) -> Optional[Path]:
406+
"""Return the skills directory if ``--ai-skills`` was used during init.
407+
408+
Reads ``.specify/init-options.json`` to determine whether skills
409+
are enabled and which agent was selected, then delegates to
410+
the module-level ``_get_skills_dir()`` helper for the concrete path.
411+
412+
Returns:
413+
The skills directory ``Path``, or ``None`` if skills were not
414+
enabled or the init-options file is missing.
415+
"""
416+
from . import load_init_options, _get_skills_dir
417+
418+
opts = load_init_options(self.project_root)
419+
if not opts.get("ai_skills"):
420+
return None
421+
422+
agent = opts.get("ai")
423+
if not agent:
424+
return None
425+
426+
skills_dir = _get_skills_dir(self.project_root, agent)
427+
if not skills_dir.is_dir():
428+
return None
429+
430+
return skills_dir
431+
432+
def _register_extension_skills(
433+
self,
434+
manifest: ExtensionManifest,
435+
extension_dir: Path,
436+
) -> List[str]:
437+
"""Generate SKILL.md files for extension commands as agent skills.
438+
439+
For every command in the extension manifest, creates a SKILL.md
440+
file in the agent's skills directory following the agentskills.io
441+
specification. This is only done when ``--ai-skills`` was used
442+
during project initialisation.
443+
444+
Args:
445+
manifest: Extension manifest.
446+
extension_dir: Installed extension directory.
447+
448+
Returns:
449+
List of skill names that were created (for registry storage).
450+
"""
451+
skills_dir = self._get_skills_dir()
452+
if not skills_dir:
453+
return []
454+
455+
from . import load_init_options
456+
import yaml
457+
458+
opts = load_init_options(self.project_root)
459+
selected_ai = opts.get("ai", "")
460+
461+
written: List[str] = []
462+
463+
for cmd_info in manifest.commands:
464+
cmd_name = cmd_info["name"]
465+
cmd_file_rel = cmd_info["file"]
466+
source_file = extension_dir / cmd_file_rel
467+
if not source_file.exists():
468+
continue
469+
470+
# Derive skill name from command name
471+
# e.g. "speckit.jira.create" -> "speckit-jira-create" (or dot for kimi)
472+
if selected_ai == "kimi":
473+
skill_name = cmd_name # Keep dot notation for kimi
474+
else:
475+
skill_name = cmd_name.replace(".", "-")
476+
477+
# Check if skill already exists before creating the directory
478+
skill_subdir = skills_dir / skill_name
479+
skill_file = skill_subdir / "SKILL.md"
480+
if skill_file.exists():
481+
# Do not overwrite user-customized skills
482+
continue
483+
484+
# Create skill directory only when we're going to write to it
485+
skill_subdir.mkdir(parents=True, exist_ok=True)
486+
487+
# Parse the command file
488+
content = source_file.read_text(encoding="utf-8")
489+
if content.startswith("---"):
490+
parts = content.split("---", 2)
491+
if len(parts) >= 3:
492+
try:
493+
frontmatter = yaml.safe_load(parts[1])
494+
except yaml.YAMLError:
495+
frontmatter = {}
496+
if not isinstance(frontmatter, dict):
497+
frontmatter = {}
498+
body = parts[2].strip()
499+
else:
500+
frontmatter = {}
501+
body = content
502+
else:
503+
frontmatter = {}
504+
body = content
505+
506+
original_desc = frontmatter.get("description", "")
507+
description = original_desc or f"Extension command: {cmd_name}"
508+
509+
frontmatter_data = {
510+
"name": skill_name,
511+
"description": description,
512+
"compatibility": "Requires spec-kit project structure with .specify/ directory",
513+
"metadata": {
514+
"author": "github-spec-kit",
515+
"source": f"extension:{manifest.id}",
516+
},
517+
}
518+
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
519+
520+
# Derive a human-friendly title from the command name
521+
short_name = cmd_name
522+
if short_name.startswith("speckit."):
523+
short_name = short_name[len("speckit."):]
524+
525+
skill_content = (
526+
f"---\n"
527+
f"{frontmatter_text}\n"
528+
f"---\n\n"
529+
f"# {skill_name.replace('-', ' ').replace('.', ' ').title()} Skill\n\n"
530+
f"{body}\n"
531+
)
532+
533+
skill_file.write_text(skill_content, encoding="utf-8")
534+
written.append(skill_name)
535+
536+
return written
537+
538+
def _unregister_extension_skills(self, skill_names: List[str]) -> None:
539+
"""Remove SKILL.md directories for extension skills.
540+
541+
Called during extension removal to clean up skill files that
542+
were created by ``_register_extension_skills()``.
543+
544+
If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed
545+
init-options.json or toggled ai_skills after installation), we
546+
fall back to scanning all known agent skills directories so that
547+
orphaned skill directories are still cleaned up.
548+
549+
Args:
550+
skill_names: List of skill names to remove.
551+
"""
552+
if not skill_names:
553+
return
554+
555+
skills_dir = self._get_skills_dir()
556+
557+
if skills_dir:
558+
# Fast path: we know the exact skills directory
559+
for skill_name in skill_names:
560+
skill_subdir = skills_dir / skill_name
561+
if skill_subdir.exists():
562+
shutil.rmtree(skill_subdir)
563+
else:
564+
# Fallback: scan all possible agent skills directories
565+
from . import AGENT_CONFIG, AGENT_SKILLS_DIR_OVERRIDES, DEFAULT_SKILLS_DIR
566+
567+
candidate_dirs: set[Path] = set()
568+
for override_path in AGENT_SKILLS_DIR_OVERRIDES.values():
569+
candidate_dirs.add(self.project_root / override_path)
570+
for cfg in AGENT_CONFIG.values():
571+
folder = cfg.get("folder", "")
572+
if folder:
573+
candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills")
574+
candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR)
575+
576+
for skills_candidate in candidate_dirs:
577+
if not skills_candidate.is_dir():
578+
continue
579+
for skill_name in skill_names:
580+
skill_subdir = skills_candidate / skill_name
581+
if skill_subdir.exists():
582+
shutil.rmtree(skill_subdir)
583+
405584
def check_compatibility(
406585
self,
407586
manifest: ExtensionManifest,
@@ -487,6 +666,10 @@ def install_from_directory(
487666
manifest, dest_dir, self.project_root
488667
)
489668

669+
# Auto-register extension commands as agent skills when --ai-skills
670+
# was used during project initialisation (feature parity).
671+
registered_skills = self._register_extension_skills(manifest, dest_dir)
672+
490673
# Register hooks
491674
hook_executor = HookExecutor(self.project_root)
492675
hook_executor.register_hooks(manifest)
@@ -497,7 +680,8 @@ def install_from_directory(
497680
"source": "local",
498681
"manifest_hash": manifest.get_hash(),
499682
"enabled": True,
500-
"registered_commands": registered_commands
683+
"registered_commands": registered_commands,
684+
"registered_skills": registered_skills,
501685
})
502686

503687
return manifest
@@ -569,9 +753,10 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
569753
if not self.registry.is_installed(extension_id):
570754
return False
571755

572-
# Get registered commands before removal
756+
# Get registered commands and skills before removal
573757
metadata = self.registry.get(extension_id)
574758
registered_commands = metadata.get("registered_commands", {})
759+
registered_skills = metadata.get("registered_skills", [])
575760

576761
extension_dir = self.extensions_dir / extension_id
577762

@@ -580,6 +765,9 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
580765
registrar = CommandRegistrar()
581766
registrar.unregister_commands(registered_commands, self.project_root)
582767

768+
# Unregister agent skills
769+
self._unregister_extension_skills(registered_skills)
770+
583771
if keep_config:
584772
# Preserve config files, only remove non-config files
585773
if extension_dir.exists():

0 commit comments

Comments
 (0)