Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to to [Semantic Versioning](https://semver.org/spec/v2.

### Changed

- **Breaking**: Aligned `--ai-skills` skill names with the `adlc.*` command namespace
- Skills now strip `adlc.` prefix and use hyphens (e.g., `spec-specify`, `tdd-plan`, `levelup-init`)
- Kimi agent uses dot notation (e.g., `spec.specify`, `levelup.specify`)
- Matches the short command form used by extensions
- Added February 2026 newsletter (#1812)
- feat: add Kimi Code CLI agent support (#1790)
- docs: fix broken links in quickstart guide (#1759) (#1797)
Expand Down
25 changes: 13 additions & 12 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2480,37 +2480,38 @@ def install_ai_skills(
body = content

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

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

# Select the best description available
original_desc = frontmatter.get("description", "")
# Extract the command name (last part after dot) for SKILL_DESCRIPTIONS lookup
command_key = command_name.split(".")[-1]
enhanced_desc = SKILL_DESCRIPTIONS.get(
command_name,
command_key,
original_desc or f"Spec-kit workflow command: {command_name}",
)

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

frontmatter_data = {
"name": skill_name,
Expand All @@ -2526,7 +2527,7 @@ def install_ai_skills(
f"---\n"
f"{frontmatter_text}\n"
f"---\n\n"
f"# Speckit {command_name.title()} Skill\n\n"
f"# Spec-kit {command_name.title()} Skill\n\n"
f"{body}\n"
)

Expand Down
109 changes: 53 additions & 56 deletions tests/test_ai_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ def templates_dir(project_dir):
tpl_root = project_dir / ".claude" / "commands"
tpl_root.mkdir(parents=True, exist_ok=True)

# Template with valid YAML frontmatter
(tpl_root / "specify.md").write_text(
# Template with valid YAML frontmatter - simulating adlc.spec.specify
(tpl_root / "adlc.spec.specify.md").write_text(
"---\n"
"description: Create or update the feature specification.\n"
"handoffs:\n"
" - label: Build Plan\n"
" agent: speckit.plan\n"
" agent: adlc.tdd.plan\n"
"scripts:\n"
" sh: scripts/bash/create-new-feature.sh\n"
"---\n"
Expand All @@ -79,8 +79,8 @@ def templates_dir(project_dir):
encoding="utf-8",
)

# Template with minimal frontmatter
(tpl_root / "plan.md").write_text(
# Template with minimal frontmatter - simulating adlc.tdd.plan
(tpl_root / "adlc.tdd.plan.md").write_text(
"---\n"
"description: Generate implementation plan.\n"
"---\n"
Expand All @@ -91,14 +91,14 @@ def templates_dir(project_dir):
encoding="utf-8",
)

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

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

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

Expand Down Expand Up @@ -204,36 +204,36 @@ def test_skills_installed_with_correct_structure(self, project_dir, templates_di
skills_dir = project_dir / ".claude" / "skills"
assert skills_dir.exists()

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

# Verify SKILL.md content for speckit-specify
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
# Verify SKILL.md content for spec-specify
skill_file = skills_dir / "spec-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()

# Check agentskills.io frontmatter
assert content.startswith("---\n")
assert "name: speckit-specify" in content
assert "name: spec-specify" in content
assert "description:" in content
assert "compatibility:" in content
assert "metadata:" in content
assert "author: github-spec-kit" in content
assert "source: templates/commands/specify.md" in content
assert "source: templates/commands/spec.specify.md" in content

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

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

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

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

def test_empty_yaml_frontmatter(self, project_dir, templates_dir):
Expand All @@ -252,12 +252,10 @@ def test_empty_yaml_frontmatter(self, project_dir, templates_dir):

assert result is True

skill_file = (
project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md"
)
skill_file = project_dir / ".claude" / "skills" / "levelup-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "name: speckit-empty_fm" in content
assert "name: levelup-specify" in content
assert "Body with empty frontmatter." in content

def test_enhanced_descriptions_used_when_available(
Expand All @@ -266,7 +264,7 @@ def test_enhanced_descriptions_used_when_available(
"""SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions."""
install_ai_skills(project_dir, "claude")

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

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

skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
skill_file = project_dir / ".claude" / "skills" / "tdd-tasks" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()

# Should still have valid SKILL.md structure
assert "name: speckit-tasks" in content
assert "name: tdd-tasks" in content
assert "Body without frontmatter." in content

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

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

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

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

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

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

install_ai_skills(project_dir, "claude")

# Commands must still be there — install_ai_skills never touches them
remaining = list(commands_dir_claude.glob("speckit.*"))
assert len(remaining) == 3
remaining = list(commands_dir_claude.glob("adlc.*"))
assert len(remaining) == 4

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

install_ai_skills(project_dir, "gemini")

remaining = list(commands_dir_gemini.glob("speckit.*"))
remaining = list(commands_dir_gemini.glob("adlc.*"))
assert len(remaining) == 3

def test_commands_dir_not_removed(
Expand Down Expand Up @@ -485,7 +486,7 @@ def _fake_extract(self, agent, project_path, **_kwargs):
if agent_folder:
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
cmds_dir.mkdir(parents=True, exist_ok=True)
(cmds_dir / "speckit.specify.md").write_text("# spec")
(cmds_dir / "adlc.spec.specify.md").write_text("# spec")

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

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

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


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

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

# But other skills should still be installed
assert result is True
assert (
project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
assert (
project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md"
).exists()
assert (project_dir / ".claude" / "skills" / "tdd-plan" / "SKILL.md").exists()
assert (project_dir / ".claude" / "skills" / "tdd-tasks" / "SKILL.md").exists()

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