Skip to content
12 changes: 7 additions & 5 deletions src/google/adk/skills/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ def _load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
# Use model_validate to handle aliases like allowed-tools
frontmatter = models.Frontmatter.model_validate(parsed)

# Validate that skill name matches the directory name
if skill_dir.name != frontmatter.name:
# Validate that skill name matches the directory name.
# Allow underscore directories to match kebab-case frontmatter names,
# since Python cannot import modules whose names contain hyphens.
if skill_dir.name.replace("_", "-") != frontmatter.name:
raise ValueError(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_dir.name}'."
Expand Down Expand Up @@ -222,7 +224,7 @@ def _validate_skill_dir(
problems.append(f"Frontmatter validation error: {e}")
return problems

if skill_dir.name != frontmatter.name:
if skill_dir.name.replace("_", "-") != frontmatter.name:
problems.append(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_dir.name}'."
Expand Down Expand Up @@ -281,7 +283,7 @@ def _list_skills_in_dir(
skill_id = skill_dir.name
try:
frontmatter = _read_skill_properties(skill_dir)
if skill_id != frontmatter.name:
if skill_id.replace("_", "-") != frontmatter.name:
raise ValueError(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_id}'."
Expand Down Expand Up @@ -387,7 +389,7 @@ def _load_skill_from_gcs_dir(

# Validate that skill name matches the directory name
skill_name_expected = skill_id.strip("/").split("/")[-1]
if skill_name_expected != frontmatter.name:
if skill_name_expected.replace("_", "-") != frontmatter.name:
raise ValueError(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_name_expected}'."
Expand Down
3 changes: 3 additions & 0 deletions src/google/adk/skills/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def _validate_metadata(cls, v: dict[str, Any]) -> dict[str, Any]:
@classmethod
def _validate_name(cls, v: str) -> str:
v = unicodedata.normalize("NFKC", v)
# Normalize underscores to hyphens so snake_case directory names
# work transparently (Python cannot import modules with hyphens).
v = v.replace("_", "-")
if len(v) > 64:
raise ValueError("name must be at most 64 characters")
if not _NAME_PATTERN.match(v):
Expand Down
54 changes: 54 additions & 0 deletions tests/unittests/skills/test__utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,57 @@ def test_list_skills_in_dir_missing_base_path(tmp_path):

skills = list_skills_in_dir(tmp_path / "nonexistent")
assert skills == {}


# --- Underscore directory / kebab-case name compatibility tests ---


def test_load_skill_underscore_dir_kebab_name(tmp_path):
"""Tests loading a skill from underscore dir with kebab-case name."""
skill_dir = tmp_path / "my_skill"
skill_dir.mkdir()

skill_md = """---
name: my-skill
description: A skill
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)

skill = _load_skill_from_dir(skill_dir)
assert skill.name == "my-skill"


def test_validate_skill_dir_underscore_dir_kebab_name(tmp_path):
"""Tests validate_skill_dir accepts underscore dir with kebab name."""
skill_dir = tmp_path / "my_skill"
skill_dir.mkdir()

skill_md = """---
name: my-skill
description: A skill
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)

problems = _validate_skill_dir(skill_dir)
assert problems == []


def test_list_skills_underscore_dir(tmp_path):
"""Tests listing skills with underscore directory names."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()

skill_dir = skills_dir / "my_skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: my-skill\ndescription: desc\n---\nbody"
)

skills = list_skills_in_dir(skills_dir)
assert len(skills) == 1
assert "my_skill" in skills
assert skills["my_skill"].name == "my-skill"
13 changes: 10 additions & 3 deletions tests/unittests/skills/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,16 @@ def test_name_consecutive_hyphens():
models.Frontmatter(name="my--skill", description="desc")


def test_name_invalid_chars_underscore():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="my_skill", description="desc")
def test_name_underscore_normalized_to_kebab():
"""Tests that underscores in name are normalized to hyphens."""
fm = models.Frontmatter(name="my_skill", description="desc")
assert fm.name == "my-skill"


def test_name_mixed_underscore_hyphen():
"""Tests mixed underscores and hyphens are normalized."""
fm = models.Frontmatter(name="my_cool-skill", description="desc")
assert fm.name == "my-cool-skill"


def test_name_invalid_chars_ampersand():
Expand Down