Skip to content

Commit b0e049e

Browse files
authored
fix: align --ai-skills prefix with adlc.* command namespace (#77)
- Strip adlc. prefix and convert dots to hyphens for skill names - Skill names now use short command form (spec-specify, tdd-plan, levelup-init) - Kimi agent uses dot notation (spec.specify) - Update SKILL_DESCRIPTIONS lookup for namespaced commands
1 parent b5e2d1f commit b0e049e

3 files changed

Lines changed: 70 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to to [Semantic Versioning](https://semver.org/spec/v2.
1111

1212
### Changed
1313

14+
- **Breaking**: Aligned `--ai-skills` skill names with the `adlc.*` command namespace
15+
- Skills now strip `adlc.` prefix and use hyphens (e.g., `spec-specify`, `tdd-plan`, `levelup-init`)
16+
- Kimi agent uses dot notation (e.g., `spec.specify`, `levelup.specify`)
17+
- Matches the short command form used by extensions
1418
- Added February 2026 newsletter (#1812)
1519
- feat: add Kimi Code CLI agent support (#1790)
1620
- docs: fix broken links in quickstart guide (#1759) (#1797)

src/specify_cli/__init__.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2480,37 +2480,38 @@ def install_ai_skills(
24802480
body = content
24812481

24822482
command_name = command_file.stem
2483-
# Normalize: extracted commands may be named "speckit.<cmd>.md";
2484-
# strip the "speckit." prefix so skill names stay clean and
2485-
# SKILL_DESCRIPTIONS lookups work.
2486-
if command_name.startswith("speckit."):
2487-
command_name = command_name[len("speckit.") :]
2483+
# Normalize: extracted commands may be named "adlc.<ext>.<cmd>.md";
2484+
# strip the "adlc." prefix and convert dots to hyphens for clean skill names.
2485+
if command_name.startswith("adlc."):
2486+
command_name = command_name[len("adlc.") :]
24882487
# Kimi uses dot separator for skill names, others use hyphen
24892488
if selected_ai == "kimi":
2490-
skill_name = f"speckit.{command_name}"
2489+
skill_name = command_name.replace(".", ".")
24912490
else:
2492-
skill_name = f"speckit-{command_name}"
2491+
skill_name = command_name.replace(".", "-")
24932492

24942493
# Create skill directory (additive — never removes existing content)
24952494
skill_dir = skills_dir / skill_name
24962495
skill_dir.mkdir(parents=True, exist_ok=True)
24972496

24982497
# Select the best description available
24992498
original_desc = frontmatter.get("description", "")
2499+
# Extract the command name (last part after dot) for SKILL_DESCRIPTIONS lookup
2500+
command_key = command_name.split(".")[-1]
25002501
enhanced_desc = SKILL_DESCRIPTIONS.get(
2501-
command_name,
2502+
command_key,
25022503
original_desc or f"Spec-kit workflow command: {command_name}",
25032504
)
25042505

25052506
# Build SKILL.md following agentskills.io spec
25062507
# Use yaml.safe_dump to safely serialise the frontmatter and
25072508
# avoid YAML injection from descriptions containing colons,
25082509
# quotes, or newlines.
2509-
# Normalize source filename for metadata — strip speckit. prefix
2510+
# Normalize source filename for metadata — strip adlc. prefix
25102511
# so it matches the canonical templates/commands/<cmd>.md path.
25112512
source_name = command_file.name
2512-
if source_name.startswith("speckit."):
2513-
source_name = source_name[len("speckit.") :]
2513+
if source_name.startswith("adlc."):
2514+
source_name = source_name[len("adlc.") :]
25142515

25152516
frontmatter_data = {
25162517
"name": skill_name,
@@ -2526,7 +2527,7 @@ def install_ai_skills(
25262527
f"---\n"
25272528
f"{frontmatter_text}\n"
25282529
f"---\n\n"
2529-
f"# Speckit {command_name.title()} Skill\n\n"
2530+
f"# Spec-kit {command_name.title()} Skill\n\n"
25302531
f"{body}\n"
25312532
)
25322533

tests/test_ai_skills.py

Lines changed: 53 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ def templates_dir(project_dir):
6262
tpl_root = project_dir / ".claude" / "commands"
6363
tpl_root.mkdir(parents=True, exist_ok=True)
6464

65-
# Template with valid YAML frontmatter
66-
(tpl_root / "specify.md").write_text(
65+
# Template with valid YAML frontmatter - simulating adlc.spec.specify
66+
(tpl_root / "adlc.spec.specify.md").write_text(
6767
"---\n"
6868
"description: Create or update the feature specification.\n"
6969
"handoffs:\n"
7070
" - label: Build Plan\n"
71-
" agent: speckit.plan\n"
71+
" agent: adlc.tdd.plan\n"
7272
"scripts:\n"
7373
" sh: scripts/bash/create-new-feature.sh\n"
7474
"---\n"
@@ -79,8 +79,8 @@ def templates_dir(project_dir):
7979
encoding="utf-8",
8080
)
8181

82-
# Template with minimal frontmatter
83-
(tpl_root / "plan.md").write_text(
82+
# Template with minimal frontmatter - simulating adlc.tdd.plan
83+
(tpl_root / "adlc.tdd.plan.md").write_text(
8484
"---\n"
8585
"description: Generate implementation plan.\n"
8686
"---\n"
@@ -91,14 +91,14 @@ def templates_dir(project_dir):
9191
encoding="utf-8",
9292
)
9393

94-
# Template with no frontmatter
95-
(tpl_root / "tasks.md").write_text(
94+
# Template with no frontmatter - simulating adlc.tdd.tasks
95+
(tpl_root / "adlc.tdd.tasks.md").write_text(
9696
"# Tasks Command\n\nBody without frontmatter.\n",
9797
encoding="utf-8",
9898
)
9999

100-
# Template with empty YAML frontmatter (yaml.safe_load returns None)
101-
(tpl_root / "empty_fm.md").write_text(
100+
# Template with empty YAML frontmatter - simulating adlc.levelup.specify
101+
(tpl_root / "adlc.levelup.specify.md").write_text(
102102
"---\n---\n\n# Empty Frontmatter Command\n\nBody with empty frontmatter.\n",
103103
encoding="utf-8",
104104
)
@@ -111,7 +111,7 @@ def commands_dir_claude(project_dir):
111111
"""Create a populated .claude/commands directory simulating template extraction."""
112112
cmd_dir = project_dir / ".claude" / "commands"
113113
cmd_dir.mkdir(parents=True, exist_ok=True)
114-
for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]:
114+
for name in ["adlc.spec.specify.md", "adlc.tdd.plan.md", "adlc.tdd.tasks.md"]:
115115
(cmd_dir / name).write_text(f"# {name}\nContent here\n")
116116
return cmd_dir
117117

@@ -121,7 +121,7 @@ def commands_dir_gemini(project_dir):
121121
"""Create a populated .gemini/commands directory (TOML format)."""
122122
cmd_dir = project_dir / ".gemini" / "commands"
123123
cmd_dir.mkdir(parents=True)
124-
for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]:
124+
for name in ["adlc.spec.specify.toml", "adlc.tdd.plan.toml", "adlc.tdd.tasks.toml"]:
125125
(cmd_dir / name).write_text(f'[command]\nname = "{name}"\n')
126126
return cmd_dir
127127

@@ -204,36 +204,36 @@ def test_skills_installed_with_correct_structure(self, project_dir, templates_di
204204
skills_dir = project_dir / ".claude" / "skills"
205205
assert skills_dir.exists()
206206

207-
# Check that skill directories were created
207+
# Check that skill directories were created (adlc. prefix stripped, dots->hyphens)
208208
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
209-
assert "speckit-plan" in skill_dirs
210-
assert "speckit-specify" in skill_dirs
211-
assert "speckit-tasks" in skill_dirs
212-
assert "speckit-empty_fm" in skill_dirs
209+
assert "tdd-plan" in skill_dirs
210+
assert "spec-specify" in skill_dirs
211+
assert "tdd-tasks" in skill_dirs
212+
assert "levelup-specify" in skill_dirs
213213

214-
# Verify SKILL.md content for speckit-specify
215-
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
214+
# Verify SKILL.md content for spec-specify
215+
skill_file = skills_dir / "spec-specify" / "SKILL.md"
216216
assert skill_file.exists()
217217
content = skill_file.read_text()
218218

219219
# Check agentskills.io frontmatter
220220
assert content.startswith("---\n")
221-
assert "name: speckit-specify" in content
221+
assert "name: spec-specify" in content
222222
assert "description:" in content
223223
assert "compatibility:" in content
224224
assert "metadata:" in content
225225
assert "author: github-spec-kit" in content
226-
assert "source: templates/commands/specify.md" in content
226+
assert "source: templates/commands/spec.specify.md" in content
227227

228-
# Check body content is included
229-
assert "# Speckit Specify Skill" in content
228+
# Check body content is included (uses original command_name with dots for title)
229+
assert "# Spec-kit Spec.Specify Skill" in content
230230
assert "Run this to create a spec." in content
231231

232232
def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):
233233
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
234234
install_ai_skills(project_dir, "claude")
235235

236-
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
236+
skill_file = project_dir / ".claude" / "skills" / "spec-specify" / "SKILL.md"
237237
content = skill_file.read_text()
238238

239239
# Extract and parse frontmatter
@@ -243,7 +243,7 @@ def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):
243243
parsed = yaml.safe_load(parts[1])
244244
assert isinstance(parsed, dict)
245245
assert "name" in parsed
246-
assert parsed["name"] == "speckit-specify"
246+
assert parsed["name"] == "spec-specify"
247247
assert "description" in parsed
248248

249249
def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
@@ -252,12 +252,10 @@ def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
252252

253253
assert result is True
254254

255-
skill_file = (
256-
project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md"
257-
)
255+
skill_file = project_dir / ".claude" / "skills" / "levelup-specify" / "SKILL.md"
258256
assert skill_file.exists()
259257
content = skill_file.read_text()
260-
assert "name: speckit-empty_fm" in content
258+
assert "name: levelup-specify" in content
261259
assert "Body with empty frontmatter." in content
262260

263261
def test_enhanced_descriptions_used_when_available(
@@ -266,7 +264,7 @@ def test_enhanced_descriptions_used_when_available(
266264
"""SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions."""
267265
install_ai_skills(project_dir, "claude")
268266

269-
skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
267+
skill_file = project_dir / ".claude" / "skills" / "spec-specify" / "SKILL.md"
270268
content = skill_file.read_text()
271269

272270
# Parse the generated YAML to compare the description value
@@ -281,12 +279,12 @@ def test_template_without_frontmatter(self, project_dir, templates_dir):
281279
"""Templates without YAML frontmatter should still produce valid skills."""
282280
install_ai_skills(project_dir, "claude")
283281

284-
skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
282+
skill_file = project_dir / ".claude" / "skills" / "tdd-tasks" / "SKILL.md"
285283
assert skill_file.exists()
286284
content = skill_file.read_text()
287285

288286
# Should still have valid SKILL.md structure
289-
assert "name: speckit-tasks" in content
287+
assert "name: tdd-tasks" in content
290288
assert "Body without frontmatter." in content
291289

292290
def test_missing_templates_directory(self, project_dir):
@@ -375,8 +373,10 @@ def test_non_md_commands_dir_falls_back(self, project_dir):
375373
# Simulate gemini template extraction: .gemini/commands/ with .toml files only
376374
cmds_dir = project_dir / ".gemini" / "commands"
377375
cmds_dir.mkdir(parents=True)
378-
(cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n')
379-
(cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n')
376+
(cmds_dir / "adlc.spec.specify.toml").write_text(
377+
'[command]\nname = "specify"\n'
378+
)
379+
(cmds_dir / "adlc.tdd.plan.toml").write_text('[command]\nname = "plan"\n')
380380

381381
# The __file__ fallback should find the real repo templates/commands/*.md
382382
result = install_ai_skills(project_dir, "gemini")
@@ -388,7 +388,7 @@ def test_non_md_commands_dir_falls_back(self, project_dir):
388388
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
389389
assert len(skill_dirs) >= 1
390390
# .toml commands should be untouched
391-
assert (cmds_dir / "speckit.specify.toml").exists()
391+
assert (cmds_dir / "adlc.spec.specify.toml").exists()
392392

393393
@pytest.mark.parametrize(
394394
"agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]
@@ -400,9 +400,10 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
400400

401401
# Place .md templates in the agent's commands directory
402402
agent_folder = AGENT_CONFIG[agent_key]["folder"]
403-
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
403+
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
404+
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
404405
cmds_dir.mkdir(parents=True)
405-
(cmds_dir / "specify.md").write_text(
406+
(cmds_dir / "adlc.spec.specify.md").write_text(
406407
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
407408
)
408409

@@ -412,9 +413,9 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
412413
skills_dir = _get_skills_dir(proj, agent_key)
413414
assert skills_dir.exists()
414415
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
415-
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation;
416-
# all other agents use hyphen-separator (speckit-specify).
417-
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
416+
# Kimi uses dot-separator (spec.specify) to match /skill:spec.* invocation;
417+
# all other agents use hyphen-separator (spec-specify).
418+
expected_skill_name = "spec.specify" if agent_key == "kimi" else "spec-specify"
418419
assert expected_skill_name in skill_dirs
419420
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
420421

@@ -432,23 +433,23 @@ def test_existing_commands_preserved_claude(
432433
):
433434
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
434435
# Verify commands exist before
435-
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
436+
assert len(list(commands_dir_claude.glob("adlc.*"))) == 4
436437

437438
install_ai_skills(project_dir, "claude")
438439

439440
# Commands must still be there — install_ai_skills never touches them
440-
remaining = list(commands_dir_claude.glob("speckit.*"))
441-
assert len(remaining) == 3
441+
remaining = list(commands_dir_claude.glob("adlc.*"))
442+
assert len(remaining) == 4
442443

443444
def test_existing_commands_preserved_gemini(
444445
self, project_dir, templates_dir, commands_dir_gemini
445446
):
446447
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
447-
assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3
448+
assert len(list(commands_dir_gemini.glob("adlc.*"))) == 3
448449

449450
install_ai_skills(project_dir, "gemini")
450451

451-
remaining = list(commands_dir_gemini.glob("speckit.*"))
452+
remaining = list(commands_dir_gemini.glob("adlc.*"))
452453
assert len(remaining) == 3
453454

454455
def test_commands_dir_not_removed(
@@ -485,7 +486,7 @@ def _fake_extract(self, agent, project_path, **_kwargs):
485486
if agent_folder:
486487
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
487488
cmds_dir.mkdir(parents=True, exist_ok=True)
488-
(cmds_dir / "speckit.specify.md").write_text("# spec")
489+
(cmds_dir / "adlc.spec.specify.md").write_text("# spec")
489490

490491
def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):
491492
"""For new projects, commands should be removed when skills succeed."""
@@ -609,7 +610,7 @@ def fake_download(project_path, *args, **kwargs):
609610
# Commands should still exist since skills failed
610611
cmds_dir = target / ".claude" / "commands"
611612
assert cmds_dir.exists()
612-
assert (cmds_dir / "speckit.specify.md").exists()
613+
assert (cmds_dir / "adlc.spec.specify.md").exists()
613614

614615
def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
615616
"""For --here on existing repos, commands must NOT be removed."""
@@ -622,7 +623,7 @@ def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
622623
agent_folder = AGENT_CONFIG["claude"]["folder"]
623624
cmds_dir = target / agent_folder.rstrip("/") / "commands"
624625
cmds_dir.mkdir(parents=True)
625-
(cmds_dir / "speckit.specify.md").write_text("# spec")
626+
(cmds_dir / "adlc.spec.specify.md").write_text("# spec")
626627

627628
# --here uses CWD, so chdir into the target
628629
monkeypatch.chdir(target)
@@ -658,7 +659,7 @@ def fake_download(project_path, *args, **kwargs):
658659
assert result.exit_code == 0
659660
# Commands must remain for --here
660661
assert cmds_dir.exists()
661-
assert (cmds_dir / "speckit.specify.md").exists()
662+
assert (cmds_dir / "adlc.spec.specify.md").exists()
662663

663664

664665
# ===== Skip-If-Exists Tests =====
@@ -669,8 +670,8 @@ class TestSkipIfExists:
669670

670671
def test_existing_skill_not_overwritten(self, project_dir, templates_dir):
671672
"""Pre-existing SKILL.md should not be replaced on re-run."""
672-
# Pre-create a custom SKILL.md for speckit-specify
673-
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
673+
# Pre-create a custom SKILL.md for spec-specify
674+
skill_dir = project_dir / ".claude" / "skills" / "spec-specify"
674675
skill_dir.mkdir(parents=True)
675676
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
676677
(skill_dir / "SKILL.md").write_text(custom_content)
@@ -682,12 +683,8 @@ def test_existing_skill_not_overwritten(self, project_dir, templates_dir):
682683

683684
# But other skills should still be installed
684685
assert result is True
685-
assert (
686-
project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
687-
).exists()
688-
assert (
689-
project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
690-
).exists()
686+
assert (project_dir / ".claude" / "skills" / "tdd-plan" / "SKILL.md").exists()
687+
assert (project_dir / ".claude" / "skills" / "tdd-tasks" / "SKILL.md").exists()
691688

692689
def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
693690
"""On first install (no pre-existing skills), all should be written."""

0 commit comments

Comments
 (0)