Skip to content

Commit 2cc7aa2

Browse files
committed
fix: unify hyphenated skills and migrate legacy kimi dotted dirs
1 parent fb152eb commit 2cc7aa2

File tree

9 files changed

+304
-45
lines changed

9 files changed

+304
-45
lines changed

.github/workflows/scripts/create-release-packages.ps1

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,7 @@ agent: $basename
202202
}
203203

204204
# Create skills in <skills_dir>\<name>\SKILL.md format.
205-
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
206-
# current dotted-name exception (e.g. speckit.plan).
205+
# Skills use hyphenated names (e.g. speckit-plan).
207206
#
208207
# Technical debt note:
209208
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
@@ -463,7 +462,7 @@ function Build-Variant {
463462
'kimi' {
464463
$skillsDir = Join-Path $baseDir ".kimi/skills"
465464
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
466-
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
465+
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi'
467466
}
468467
'trae' {
469468
$rulesDir = Join-Path $baseDir ".trae/rules"

.github/workflows/scripts/create-release-packages.sh

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,7 @@ EOF
140140
}
141141

142142
# Create skills in <skills_dir>/<name>/SKILL.md format.
143-
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
144-
# current dotted-name exception (e.g. speckit.plan).
143+
# Skills use hyphenated names (e.g. speckit-plan).
145144
#
146145
# Technical debt note:
147146
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
@@ -321,7 +320,7 @@ build_variant() {
321320
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
322321
kimi)
323322
mkdir -p "$base_dir/.kimi/skills"
324-
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
323+
create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;;
325324
trae)
326325
mkdir -p "$base_dir/.trae/rules"
327326
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;

src/specify_cli/__init__.py

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,9 +1492,7 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
14921492

14931493
# Agent-specific skill directory overrides for agents whose skills directory
14941494
# doesn't follow the standard <agent_folder>/skills/ pattern
1495-
AGENT_SKILLS_DIR_OVERRIDES = {
1496-
"codex": ".agents/skills", # Codex agent layout override
1497-
}
1495+
AGENT_SKILLS_DIR_OVERRIDES = {}
14981496

14991497
# Default skills directory for agents not in AGENT_CONFIG
15001498
DEFAULT_SKILLS_DIR = ".agents/skills"
@@ -1648,10 +1646,7 @@ def install_ai_skills(
16481646
command_name = command_name[len("speckit."):]
16491647
if command_name.endswith(".agent"):
16501648
command_name = command_name[:-len(".agent")]
1651-
if selected_ai == "kimi":
1652-
skill_name = f"speckit.{command_name}"
1653-
else:
1654-
skill_name = f"speckit-{command_name}"
1649+
skill_name = f"speckit-{command_name}"
16551650

16561651
# Create skill directory (additive — never removes existing content)
16571652
skill_dir = skills_dir / skill_name
@@ -1730,8 +1725,48 @@ def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
17301725
if not skills_dir.is_dir():
17311726
return False
17321727

1733-
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
1734-
return any(skills_dir.glob(pattern))
1728+
return any(skills_dir.glob("speckit-*/SKILL.md"))
1729+
1730+
1731+
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
1732+
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
1733+
1734+
Temporary migration helper:
1735+
- Intended removal window: after 2026-06-25.
1736+
- Purpose: one-time cleanup for projects initialized before Kimi moved to
1737+
hyphenated skills (speckit-xxx).
1738+
1739+
Returns:
1740+
Tuple[migrated_count, removed_count]
1741+
- migrated_count: old dotted dir renamed to hyphenated dir
1742+
- removed_count: old dotted dir deleted because hyphenated dir already existed
1743+
"""
1744+
if not skills_dir.is_dir():
1745+
return (0, 0)
1746+
1747+
migrated_count = 0
1748+
removed_count = 0
1749+
1750+
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
1751+
if not legacy_dir.is_dir():
1752+
continue
1753+
if not (legacy_dir / "SKILL.md").exists():
1754+
continue
1755+
1756+
suffix = legacy_dir.name[len("speckit."):]
1757+
if not suffix:
1758+
continue
1759+
1760+
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
1761+
1762+
if target_dir.exists():
1763+
shutil.rmtree(legacy_dir)
1764+
removed_count += 1
1765+
else:
1766+
shutil.move(str(legacy_dir), str(target_dir))
1767+
migrated_count += 1
1768+
1769+
return (migrated_count, removed_count)
17351770

17361771

17371772
AGENT_SKILLS_MIGRATIONS = {
@@ -2097,13 +2132,23 @@ def init(
20972132
if ai_skills:
20982133
if selected_ai in NATIVE_SKILLS_AGENTS:
20992134
skills_dir = _get_skills_dir(project_path, selected_ai)
2135+
migrated_legacy_kimi_skills = 0
2136+
removed_legacy_kimi_skills = 0
2137+
if selected_ai == "kimi":
2138+
migrated_legacy_kimi_skills, removed_legacy_kimi_skills = _migrate_legacy_kimi_dotted_skills(skills_dir)
21002139
bundled_found = _has_bundled_skills(project_path, selected_ai)
21012140
if bundled_found:
2141+
detail = f"bundled skills → {skills_dir.relative_to(project_path)}"
2142+
if migrated_legacy_kimi_skills or removed_legacy_kimi_skills:
2143+
detail += (
2144+
f" (migrated {migrated_legacy_kimi_skills}, "
2145+
f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)"
2146+
)
21022147
if tracker:
21032148
tracker.start("ai-skills")
2104-
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
2149+
tracker.complete("ai-skills", detail)
21052150
else:
2106-
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
2151+
console.print(f"[green]✓[/green] Using {detail}")
21072152
else:
21082153
# Compatibility fallback: convert command templates to skills
21092154
# when an older template archive does not include native skills.
@@ -2288,7 +2333,7 @@ def _display_cmd(name: str) -> str:
22882333
if codex_skill_mode:
22892334
return f"$speckit-{name}"
22902335
if kimi_skill_mode:
2291-
return f"/skill:speckit.{name}"
2336+
return f"/skill:speckit-{name}"
22922337
return f"/speckit.{name}"
22932338

22942339
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")

src/specify_cli/agents.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,9 @@ def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str,
400400
short_name = cmd_name
401401
if short_name.startswith("speckit."):
402402
short_name = short_name[len("speckit."):]
403+
short_name = short_name.replace(".", "-")
403404

404-
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
405+
return f"speckit-{short_name}"
405406

406407
def register_commands(
407408
self,

src/specify_cli/extensions.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,6 +1635,8 @@ def has_value(self, key_path: str) -> bool:
16351635
class HookExecutor:
16361636
"""Manages extension hook execution."""
16371637

1638+
INIT_OPTIONS_FILE = ".specify/init-options.json"
1639+
16381640
def __init__(self, project_root: Path):
16391641
"""Initialize hook executor.
16401642
@@ -1645,6 +1647,49 @@ def __init__(self, project_root: Path):
16451647
self.extensions_dir = project_root / ".specify" / "extensions"
16461648
self.config_file = project_root / ".specify" / "extensions.yml"
16471649

1650+
def _load_init_options(self) -> Dict[str, Any]:
1651+
"""Load persisted init options used to determine invocation style."""
1652+
options_file = self.project_root / self.INIT_OPTIONS_FILE
1653+
if not options_file.exists():
1654+
return {}
1655+
try:
1656+
payload = json.loads(options_file.read_text(encoding="utf-8"))
1657+
return payload if isinstance(payload, dict) else {}
1658+
except (json.JSONDecodeError, OSError, UnicodeError):
1659+
return {}
1660+
1661+
@staticmethod
1662+
def _skill_name_from_command(command: str) -> str:
1663+
"""Map a command id like speckit.plan to speckit-plan skill name."""
1664+
if not isinstance(command, str):
1665+
return ""
1666+
command_id = command.strip()
1667+
if not command_id.startswith("speckit."):
1668+
return ""
1669+
return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}"
1670+
1671+
def _render_hook_invocation(self, command: str) -> str:
1672+
"""Render an agent-specific invocation string for a hook command."""
1673+
if not isinstance(command, str):
1674+
return ""
1675+
1676+
command_id = command.strip()
1677+
if not command_id:
1678+
return ""
1679+
1680+
init_options = self._load_init_options()
1681+
selected_ai = init_options.get("ai")
1682+
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
1683+
kimi_skill_mode = selected_ai == "kimi"
1684+
1685+
skill_name = self._skill_name_from_command(command_id)
1686+
if codex_skill_mode and skill_name:
1687+
return f"${skill_name}"
1688+
if kimi_skill_mode and skill_name:
1689+
return f"/skill:{skill_name}"
1690+
1691+
return f"/{command_id}"
1692+
16481693
def get_project_config(self) -> Dict[str, Any]:
16491694
"""Load project-level extension configuration.
16501695
@@ -1887,21 +1932,27 @@ def format_hook_message(
18871932
for hook in hooks:
18881933
extension = hook.get("extension")
18891934
command = hook.get("command")
1935+
invocation = self._render_hook_invocation(command)
18901936
optional = hook.get("optional", True)
18911937
prompt = hook.get("prompt", "")
18921938
description = hook.get("description", "")
18931939

18941940
if optional:
18951941
lines.append(f"\n**Optional Hook**: {extension}")
1896-
lines.append(f"Command: `/{command}`")
1942+
if invocation:
1943+
lines.append(f"Command: `{invocation}`")
18971944
if description:
18981945
lines.append(f"Description: {description}")
18991946
lines.append(f"\nPrompt: {prompt}")
1900-
lines.append(f"To execute: `/{command}`")
1947+
if invocation:
1948+
lines.append(f"To execute: `{invocation}`")
19011949
else:
19021950
lines.append(f"\n**Automatic Hook**: {extension}")
1903-
lines.append(f"Executing: `/{command}`")
1951+
if invocation:
1952+
lines.append(f"Executing: `{invocation}`")
19041953
lines.append(f"EXECUTE_COMMAND: {command}")
1954+
if invocation:
1955+
lines.append(f"EXECUTE_COMMAND_INVOCATION: {invocation}")
19051956

19061957
return "\n".join(lines)
19071958

@@ -1965,6 +2016,7 @@ def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]:
19652016
"""
19662017
return {
19672018
"command": hook.get("command"),
2019+
"invocation": self._render_hook_invocation(hook.get("command")),
19682020
"extension": hook.get("extension"),
19692021
"optional": hook.get("optional", True),
19702022
"description": hook.get("description", ""),
@@ -2008,4 +2060,3 @@ def disable_hooks(self, extension_id: str):
20082060
hook["enabled"] = False
20092061

20102062
self.save_project_config(config)
2011-

src/specify_cli/presets.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -628,10 +628,7 @@ def _register_skills(
628628
if not skills_dir:
629629
return []
630630

631-
from . import SKILL_DESCRIPTIONS, load_init_options
632-
633-
opts = load_init_options(self.project_root)
634-
selected_ai = opts.get("ai", "")
631+
from . import SKILL_DESCRIPTIONS
635632

636633
written: List[str] = []
637634

@@ -646,10 +643,8 @@ def _register_skills(
646643
short_name = cmd_name
647644
if short_name.startswith("speckit."):
648645
short_name = short_name[len("speckit."):]
649-
if selected_ai == "kimi":
650-
skill_name = f"speckit.{short_name}"
651-
else:
652-
skill_name = f"speckit-{short_name}"
646+
short_name = short_name.replace(".", "-")
647+
skill_name = f"speckit-{short_name}"
653648

654649
# Only overwrite if the skill already exists (i.e. --ai-skills was used)
655650
skill_subdir = skills_dir / skill_name

tests/test_ai_skills.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from specify_cli import (
2626
_get_skills_dir,
27+
_migrate_legacy_kimi_dotted_skills,
2728
install_ai_skills,
2829
AGENT_SKILLS_DIR_OVERRIDES,
2930
DEFAULT_SKILLS_DIR,
@@ -169,8 +170,8 @@ def test_copilot_skills_dir(self, project_dir):
169170
result = _get_skills_dir(project_dir, "copilot")
170171
assert result == project_dir / ".github" / "skills"
171172

172-
def test_codex_uses_override(self, project_dir):
173-
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
173+
def test_codex_skills_dir_from_agent_config(self, project_dir):
174+
"""Codex should resolve skills directory from AGENT_CONFIG folder."""
174175
result = _get_skills_dir(project_dir, "codex")
175176
assert result == project_dir / ".agents" / "skills"
176177

@@ -211,6 +212,39 @@ def test_override_takes_precedence_over_config(self, project_dir):
211212
assert result == expected
212213

213214

215+
class TestKimiLegacySkillMigration:
216+
"""Test temporary migration from Kimi dotted skill names to hyphenated names."""
217+
218+
def test_migrates_legacy_dotted_skill_directory(self, project_dir):
219+
skills_dir = project_dir / ".kimi" / "skills"
220+
legacy_dir = skills_dir / "speckit.plan"
221+
legacy_dir.mkdir(parents=True)
222+
(legacy_dir / "SKILL.md").write_text("legacy")
223+
224+
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
225+
226+
assert migrated == 1
227+
assert removed == 0
228+
assert not legacy_dir.exists()
229+
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
230+
231+
def test_removes_legacy_dir_when_hyphenated_target_exists(self, project_dir):
232+
skills_dir = project_dir / ".kimi" / "skills"
233+
legacy_dir = skills_dir / "speckit.plan"
234+
legacy_dir.mkdir(parents=True)
235+
(legacy_dir / "SKILL.md").write_text("legacy")
236+
target_dir = skills_dir / "speckit-plan"
237+
target_dir.mkdir(parents=True)
238+
(target_dir / "SKILL.md").write_text("new")
239+
240+
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
241+
242+
assert migrated == 0
243+
assert removed == 1
244+
assert not legacy_dir.exists()
245+
assert (target_dir / "SKILL.md").read_text() == "new"
246+
247+
214248
# ===== install_ai_skills Tests =====
215249

216250
class TestInstallAiSkills:
@@ -473,8 +507,7 @@ def test_skills_install_for_all_agents(self, temp_dir, agent_key):
473507
skills_dir = _get_skills_dir(proj, agent_key)
474508
assert skills_dir.exists()
475509
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
476-
# Kimi uses dotted skill names; other agents use hyphen-separated names.
477-
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
510+
expected_skill_name = "speckit-specify"
478511
assert expected_skill_name in skill_dirs
479512
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
480513

@@ -1118,12 +1151,12 @@ def _fake_download(*args, **kwargs):
11181151
assert "Optional skills that you can use for your specs" in result.output
11191152

11201153
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
1121-
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
1154+
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
11221155
from typer.testing import CliRunner
11231156

11241157
def _fake_download(*args, **kwargs):
11251158
project_path = Path(args[0])
1126-
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
1159+
skill_dir = project_path / ".kimi" / "skills" / "speckit-specify"
11271160
skill_dir.mkdir(parents=True, exist_ok=True)
11281161
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
11291162

@@ -1137,7 +1170,7 @@ def _fake_download(*args, **kwargs):
11371170
)
11381171

11391172
assert result.exit_code == 0
1140-
assert "/skill:speckit.constitution" in result.output
1173+
assert "/skill:speckit-constitution" in result.output
11411174
assert "/speckit.constitution" not in result.output
11421175
assert "Optional skills that you can use for your specs" in result.output
11431176

0 commit comments

Comments
 (0)