Skip to content

Commit bd7f6a1

Browse files
committed
Unify extension skill naming with hooks
1 parent 4aeaf5b commit bd7f6a1

2 files changed

Lines changed: 41 additions & 51 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,8 @@ def _register_extension_skills(
560560
if not skills_dir:
561561
return []
562562

563-
from . import load_init_options
564563
import yaml
565564

566-
opts = load_init_options(self.project_root)
567-
selected_ai = opts.get("ai", "")
568-
569565
written: List[str] = []
570566

571567
for cmd_info in manifest.commands:
@@ -587,17 +583,12 @@ def _register_extension_skills(
587583
if not source_file.is_file():
588584
continue
589585

590-
# Derive skill name from command name, matching the convention used by
591-
# presets.py: strip the leading "speckit." prefix, then form:
592-
# Kimi → "speckit.{short_name}" (dot preserved for Kimi agent)
593-
# other → "speckit-{short_name}" (hyphen separator)
586+
# Derive skill name from command name using the same hyphenated
587+
# convention as hook rendering and preset skill registration.
594588
short_name_raw = cmd_name
595589
if short_name_raw.startswith("speckit."):
596590
short_name_raw = short_name_raw[len("speckit."):]
597-
if selected_ai == "kimi":
598-
skill_name = f"speckit.{short_name_raw}"
599-
else:
600-
skill_name = f"speckit-{short_name_raw}"
591+
skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
601592

602593
# Check if skill already exists before creating the directory
603594
skill_subdir = skills_dir / skill_name

tests/test_extension_skills.py

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ def test_skills_created_when_ai_skills_active(self, skills_project, extension_di
208208

209209
# Check that skill directories were created
210210
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
211-
assert "speckit-test-ext.hello" in skill_dirs
212-
assert "speckit-test-ext.world" in skill_dirs
211+
assert "speckit-test-ext-hello" in skill_dirs
212+
assert "speckit-test-ext-world" in skill_dirs
213213

214214
def test_skill_md_content_correct(self, skills_project, extension_dir):
215215
"""SKILL.md should have correct agentskills.io structure."""
@@ -219,13 +219,13 @@ def test_skill_md_content_correct(self, skills_project, extension_dir):
219219
extension_dir, "0.1.0", register_commands=False
220220
)
221221

222-
skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
222+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
223223
assert skill_file.exists()
224224
content = skill_file.read_text()
225225

226226
# Check structure
227227
assert content.startswith("---\n")
228-
assert "name: speckit-test-ext.hello" in content
228+
assert "name: speckit-test-ext-hello" in content
229229
assert "description:" in content
230230
assert "Test hello command" in content
231231
assert "source: extension:test-ext" in content
@@ -241,15 +241,15 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
241241
extension_dir, "0.1.0", register_commands=False
242242
)
243243

244-
skill_file = skills_dir / "speckit-test-ext.hello" / "SKILL.md"
244+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
245245
content = skill_file.read_text()
246246

247247
assert content.startswith("---\n")
248248
parts = content.split("---", 2)
249249
assert len(parts) >= 3
250250
parsed = yaml.safe_load(parts[1])
251251
assert isinstance(parsed, dict)
252-
assert parsed["name"] == "speckit-test-ext.hello"
252+
assert parsed["name"] == "speckit-test-ext-hello"
253253
assert "description" in parsed
254254

255255
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
@@ -278,7 +278,7 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
278278
project_dir, skills_dir = skills_project
279279

280280
# Pre-create a custom skill
281-
custom_dir = skills_dir / "speckit-test-ext.hello"
281+
custom_dir = skills_dir / "speckit-test-ext-hello"
282282
custom_dir.mkdir(parents=True)
283283
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
284284
(custom_dir / "SKILL.md").write_text(custom_content)
@@ -293,9 +293,9 @@ def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
293293

294294
# But the other skill should still be created
295295
metadata = manager.registry.get(manifest.id)
296-
assert "speckit-test-ext.world" in metadata["registered_skills"]
296+
assert "speckit-test-ext-world" in metadata["registered_skills"]
297297
# The pre-existing one should NOT be in registered_skills (it was skipped)
298-
assert "speckit-test-ext.hello" not in metadata["registered_skills"]
298+
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
299299

300300
def test_registered_skills_in_registry(self, skills_project, extension_dir):
301301
"""Registry should contain registered_skills list."""
@@ -308,11 +308,11 @@ def test_registered_skills_in_registry(self, skills_project, extension_dir):
308308
metadata = manager.registry.get(manifest.id)
309309
assert "registered_skills" in metadata
310310
assert len(metadata["registered_skills"]) == 2
311-
assert "speckit-test-ext.hello" in metadata["registered_skills"]
312-
assert "speckit-test-ext.world" in metadata["registered_skills"]
311+
assert "speckit-test-ext-hello" in metadata["registered_skills"]
312+
assert "speckit-test-ext-world" in metadata["registered_skills"]
313313

314-
def test_kimi_uses_dot_notation(self, project_dir, temp_dir):
315-
"""Kimi agent should use dot notation for skill names."""
314+
def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
315+
"""Kimi agent should use the same hyphenated skill names as hooks."""
316316
_create_init_options(project_dir, ai="kimi", ai_skills=True)
317317
_create_skills_dir(project_dir, ai="kimi")
318318
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
@@ -323,9 +323,8 @@ def test_kimi_uses_dot_notation(self, project_dir, temp_dir):
323323
)
324324

325325
metadata = manager.registry.get(manifest.id)
326-
# Kimi should use dots, not hyphens
327-
assert "speckit.test-ext.hello" in metadata["registered_skills"]
328-
assert "speckit.test-ext.world" in metadata["registered_skills"]
326+
assert "speckit-test-ext-hello" in metadata["registered_skills"]
327+
assert "speckit-test-ext-world" in metadata["registered_skills"]
329328

330329
def test_missing_command_file_skipped(self, skills_project, temp_dir):
331330
"""Commands with missing source files should be skipped gracefully."""
@@ -372,8 +371,8 @@ def test_missing_command_file_skipped(self, skills_project, temp_dir):
372371
)
373372

374373
metadata = manager.registry.get(manifest.id)
375-
assert "speckit-missing-cmd-ext.exists" in metadata["registered_skills"]
376-
assert "speckit-missing-cmd-ext.ghost" not in metadata["registered_skills"]
374+
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
375+
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
377376

378377

379378
# ===== Extension Skill Unregistration Tests =====
@@ -390,16 +389,16 @@ def test_skills_removed_on_extension_remove(self, skills_project, extension_dir)
390389
)
391390

392391
# Verify skills exist
393-
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
394-
assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
392+
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
393+
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
395394

396395
# Remove extension
397396
result = manager.remove(manifest.id, keep_config=False)
398397
assert result is True
399398

400399
# Skills should be gone
401-
assert not (skills_dir / "speckit-test-ext.hello").exists()
402-
assert not (skills_dir / "speckit-test-ext.world").exists()
400+
assert not (skills_dir / "speckit-test-ext-hello").exists()
401+
assert not (skills_dir / "speckit-test-ext-world").exists()
403402

404403
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
405404
"""Non-extension skills should not be affected by extension removal."""
@@ -430,8 +429,8 @@ def test_remove_handles_already_deleted_skills(self, skills_project, extension_d
430429
)
431430

432431
# Manually delete skill dirs before calling remove
433-
shutil.rmtree(skills_dir / "speckit-test-ext.hello")
434-
shutil.rmtree(skills_dir / "speckit-test-ext.world")
432+
shutil.rmtree(skills_dir / "speckit-test-ext-hello")
433+
shutil.rmtree(skills_dir / "speckit-test-ext-world")
435434

436435
# Should not raise
437436
result = manager.remove(manifest.id, keep_config=False)
@@ -492,10 +491,10 @@ def test_command_without_frontmatter(self, skills_project, temp_dir):
492491
ext_dir, "0.1.0", register_commands=False
493492
)
494493

495-
skill_file = skills_dir / "speckit-nofm-ext.plain" / "SKILL.md"
494+
skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
496495
assert skill_file.exists()
497496
content = skill_file.read_text()
498-
assert "name: speckit-nofm-ext.plain" in content
497+
assert "name: speckit-nofm-ext-plain" in content
499498
# Fallback description when no frontmatter description
500499
assert "Extension command: speckit.nofm-ext.plain" in content
501500
assert "Body without frontmatter." in content
@@ -512,8 +511,8 @@ def test_gemini_agent_skills(self, project_dir, temp_dir):
512511
)
513512

514513
skills_dir = project_dir / ".gemini" / "skills"
515-
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
516-
assert (skills_dir / "speckit-test-ext.world" / "SKILL.md").exists()
514+
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
515+
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
517516

518517
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
519518
"""Installing and removing different extensions should be independent."""
@@ -531,15 +530,15 @@ def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
531530
)
532531

533532
# Both should have skills
534-
assert (skills_dir / "speckit-ext-a.hello" / "SKILL.md").exists()
535-
assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
533+
assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
534+
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
536535

537536
# Remove ext-a
538537
manager.remove("ext-a", keep_config=False)
539538

540539
# ext-a skills gone, ext-b skills preserved
541-
assert not (skills_dir / "speckit-ext-a.hello").exists()
542-
assert (skills_dir / "speckit-ext-b.hello" / "SKILL.md").exists()
540+
assert not (skills_dir / "speckit-ext-a-hello").exists()
541+
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
543542

544543
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
545544
"""Commands with invalid YAML frontmatter should still produce valid skills."""
@@ -588,7 +587,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
588587
ext_dir, "0.1.0", register_commands=False
589588
)
590589

591-
skill_file = skills_dir / "speckit-badfm-ext.broken" / "SKILL.md"
590+
skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
592591
assert skill_file.exists()
593592
content = skill_file.read_text()
594593
# Fallback description since frontmatter was invalid
@@ -604,7 +603,7 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens
604603
)
605604

606605
# Verify skills exist
607-
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
606+
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
608607

609608
# Delete init-options.json to simulate user change
610609
init_opts = project_dir / ".specify" / "init-options.json"
@@ -613,8 +612,8 @@ def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extens
613612
# Remove should still clean up via fallback scan
614613
result = manager.remove(manifest.id, keep_config=False)
615614
assert result is True
616-
assert not (skills_dir / "speckit-test-ext.hello").exists()
617-
assert not (skills_dir / "speckit-test-ext.world").exists()
615+
assert not (skills_dir / "speckit-test-ext-hello").exists()
616+
assert not (skills_dir / "speckit-test-ext-world").exists()
618617

619618
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
620619
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
@@ -625,13 +624,13 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension
625624
)
626625

627626
# Verify skills exist
628-
assert (skills_dir / "speckit-test-ext.hello" / "SKILL.md").exists()
627+
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
629628

630629
# Toggle ai_skills to false
631630
_create_init_options(project_dir, ai="claude", ai_skills=False)
632631

633632
# Remove should still clean up via fallback scan
634633
result = manager.remove(manifest.id, keep_config=False)
635634
assert result is True
636-
assert not (skills_dir / "speckit-test-ext.hello").exists()
637-
assert not (skills_dir / "speckit-test-ext.world").exists()
635+
assert not (skills_dir / "speckit-test-ext-hello").exists()
636+
assert not (skills_dir / "speckit-test-ext-world").exists()

0 commit comments

Comments
 (0)