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
77 changes: 75 additions & 2 deletions lib/mix/tasks/usage_rules.sync.ex
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ if Code.ensure_loaded?(Igniter) do
Igniter.exists?(igniter, Path.join(path, "usage-rules.md"))
end)
|> Enum.map(fn {pkg_name, _path, _mode} ->
{:"use-#{pkg_name}", [usage_rules: [pkg_name]]}
{:"use-#{normalize_skill_name(pkg_name)}", [usage_rules: [pkg_name]]}
end)

# Merge explicit build specs on top of package-derived ones
Expand Down Expand Up @@ -801,6 +801,8 @@ if Code.ensure_loaded?(Igniter) do

defp build_single_skill(igniter, all_deps, skill_name, skill_opts, skills_location) do
skill_name = to_string(skill_name)
igniter = warn_on_invalid_skill_name(igniter, skill_name)

skill_dir = Path.join(skills_location, skill_name)
usage_rule_specs = skill_opts[:usage_rules] || []
custom_description = skill_opts[:description]
Expand Down Expand Up @@ -885,7 +887,9 @@ if Code.ensure_loaded?(Igniter) do
end

defp build_skill_md(igniter, skill_name, resolved_packages, custom_description) do
description = custom_description || build_skill_description(skill_name, resolved_packages)
description =
(custom_description || build_skill_description(skill_name, resolved_packages))
|> truncate_description()

formatted_description = format_yaml_string(description)

Expand Down Expand Up @@ -1017,6 +1021,8 @@ if Code.ensure_loaded?(Igniter) do
skill_dirs = find_package_skill_dirs(acc, pkg_path)

Enum.reduce(skill_dirs, acc, fn skill_name, inner_acc ->
inner_acc = warn_on_invalid_skill_name(inner_acc, skill_name)

src_skill_dir = Path.join([pkg_path, "usage-rules", "skills", skill_name])
dst_skill_dir = Path.join(skills_location, skill_name)

Expand Down Expand Up @@ -1440,6 +1446,73 @@ if Code.ensure_loaded?(Igniter) do
end
end

# Normalizes a skill name to comply with the agentskills.io specification:
# https://agentskills.io/specification#name-field
#
# - Lowercases the name
# - Replaces underscores with hyphens
# - Strips invalid characters (only a-z, 0-9, hyphens allowed)
# - Collapses consecutive hyphens
# - Trims leading/trailing hyphens
# - Truncates to 64 characters
defp normalize_skill_name(name) do
name
|> to_string()
|> String.downcase()
|> String.replace(~r/[_.\s]/, "-")
|> String.replace(~r/[^a-z0-9-]/, "")
|> String.replace(~r/-{2,}/, "-")
|> String.trim("-")
|> String.slice(0, 64)
end

defp warn_on_invalid_skill_name(igniter, name) do
case validate_skill_name(name) do
:ok -> igniter
{:error, message} -> Igniter.add_warning(igniter, message)
end
end

@skill_name_pattern ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/

# Validates a skill name against the agentskills.io specification:
# https://agentskills.io/specification#name-field
defp validate_skill_name(name) when byte_size(name) > 64 do
{:error, "Skill name '#{name}' exceeds 64 characters (agentskills.io spec requires ≤ 64)"}
end

defp validate_skill_name("-" <> _ = name) do
{:error, "Skill name '#{name}' must not start or end with a hyphen (agentskills.io spec)"}
end

defp validate_skill_name(name) do
cond do
String.ends_with?(name, "-") ->
{:error,
"Skill name '#{name}' must not start or end with a hyphen (agentskills.io spec)"}

String.contains?(name, "--") ->
{:error,
"Skill name '#{name}' must not contain consecutive hyphens (agentskills.io spec)"}

not Regex.match?(@skill_name_pattern, name) ->
{:error,
"Skill name '#{name}' must only contain lowercase letters, numbers, and hyphens (agentskills.io spec)"}

true ->
:ok
end
end

# Truncates a description to the agentskills.io spec maximum of 1024 characters.
defp truncate_description(description) do
if String.length(description) > 1024 do
String.slice(description, 0, 1021) <> "..."
else
description
end
end

defp format_yaml_string(str) do
str = String.trim(str)

Expand Down
192 changes: 183 additions & 9 deletions test/mix/tasks/usage_rules.sync_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,8 +1139,8 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
"deps/req/usage-rules.md" => "# Req Rules"
})
|> sync(skills: [location: ".claude/skills", deps: [~r/^ash_/]])
|> assert_creates(".claude/skills/use-ash_postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash_json_api/SKILL.md")
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash-json-api/SKILL.md")
end

test "regex skips deps without usage-rules.md" do
Expand All @@ -1149,7 +1149,7 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
"deps/ash_no_rules/mix.exs" => "defmodule AshNoRules.MixProject, do: nil"
})
|> sync(skills: [location: ".claude/skills", deps: [~r/^ash_/]])
|> assert_creates(".claude/skills/use-ash_postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")
end

test "regex and atoms can be mixed" do
Expand All @@ -1158,7 +1158,7 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
"deps/req/usage-rules.md" => "# Req Rules"
})
|> sync(skills: [location: ".claude/skills", deps: [~r/^ash_/, :req]])
|> assert_creates(".claude/skills/use-ash_postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")
|> assert_creates(".claude/skills/use-req/SKILL.md")
end

Expand All @@ -1167,7 +1167,7 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
"deps/ash_postgres/usage-rules.md" => "# Ash Postgres"
})
|> sync(skills: [location: ".claude/skills", deps: [~r/^ash_/, :ash_postgres]])
|> assert_creates(".claude/skills/use-ash_postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")
end
end

Expand Down Expand Up @@ -1305,10 +1305,10 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
"deps/ash_json_api/usage-rules.md" => "# Ash JSON API Rules"
})
|> sync(skills: [location: ".claude/skills", deps: [{~r/^ash_/, :reference}]])
|> assert_creates(".claude/skills/use-ash_postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash_postgres/references/ash_postgres.md")
|> assert_creates(".claude/skills/use-ash_json_api/SKILL.md")
|> assert_creates(".claude/skills/use-ash_json_api/references/ash_json_api.md")
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")
|> assert_creates(".claude/skills/use-ash-postgres/references/ash_postgres.md")
|> assert_creates(".claude/skills/use-ash-json-api/SKILL.md")
|> assert_creates(".claude/skills/use-ash-json-api/references/ash_json_api.md")
end)

assert output =~ "deprecated in usage_rules skill config"
Expand Down Expand Up @@ -1704,4 +1704,178 @@ defmodule Mix.Tasks.UsageRules.SyncTest do
|> assert_unchanged()
end
end

describe "agentskills.io spec compliance" do
test "skill name uses hyphens instead of underscores (auto-built from deps)" do
igniter =
project_with_deps(%{
"deps/ash_postgres/usage-rules.md" => "# Ash Postgres"
})
|> sync(skills: [location: ".claude/skills", deps: [:ash_postgres]])
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")

content = file_content(igniter, ".claude/skills/use-ash-postgres/SKILL.md")
assert content =~ "name: use-ash-postgres"
end

test "removes old skill with underscore name when normalized name is generated" do
old_skill_md = """
---
name: use-ash_postgres
description: "Old."
metadata:
managed-by: usage-rules
---

<!-- usage-rules-skill-start -->
Old content.
<!-- usage-rules-skill-end -->
"""

igniter =
project_with_deps(%{
"deps/ash_postgres/usage-rules.md" => "# Ash Postgres Rules",
".claude/skills/use-ash_postgres/SKILL.md" => old_skill_md
})
|> sync(skills: [location: ".claude/skills", deps: [:ash_postgres]])
|> assert_creates(".claude/skills/use-ash-postgres/SKILL.md")

assert ".claude/skills/use-ash_postgres/SKILL.md" not in Map.keys(igniter.rewrite.sources)

content = file_content(igniter, ".claude/skills/use-ash-postgres/SKILL.md")
assert content =~ "name: use-ash-postgres"
assert content =~ "managed-by: usage-rules"
end

test "emits warning for build spec with non-compliant name" do
project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
my_skill: [usage_rules: [:foo]]
]
]
)
|> assert_has_warning(fn warning ->
String.contains?(warning, "must only contain lowercase letters")
end)
end

test "emits warning for skill name exceeding 64 characters" do
long_name = String.duplicate("a", 65)

project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
{String.to_atom(long_name), [usage_rules: [:foo]]}
]
]
)
|> assert_has_warning(fn warning ->
String.contains?(warning, "exceeds 64 characters")
end)
end

test "emits warning for skill name with leading hyphen" do
project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
"-bad-name": [usage_rules: [:foo]]
]
]
)
|> assert_has_warning(fn warning ->
String.contains?(warning, "must not start or end with a hyphen")
end)
end

test "emits warning for skill name with consecutive hyphens" do
project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
"bad--name": [usage_rules: [:foo]]
]
]
)
|> assert_has_warning(fn warning ->
String.contains?(warning, "must not contain consecutive hyphens")
end)
end

test "emits warning for package skill with non-compliant name" do
project_with_deps(%{
"deps/foo/usage-rules/skills/bad_name/SKILL.md" =>
"---\nname: bad_name\ndescription: \"A skill.\"\n---\nContent."
})
|> sync(skills: [location: ".claude/skills", package_skills: [:foo]])
|> assert_has_warning(fn warning ->
String.contains?(warning, "must only contain lowercase letters")
end)
end

test "description longer than 1024 characters is truncated" do
long_description = String.duplicate("x", 1100)

igniter =
project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
"use-foo": [
usage_rules: [:foo],
description: long_description
]
]
]
)
|> assert_creates(".claude/skills/use-foo/SKILL.md")

content = file_content(igniter, ".claude/skills/use-foo/SKILL.md")
# The description in the frontmatter should be truncated to 1024 chars max
# (1021 chars + "...")
refute content =~ String.duplicate("x", 1025)
assert content =~ "..."
end

test "generated description within 1024 characters is not truncated" do
igniter =
project_with_deps(%{
"deps/foo/usage-rules.md" => "# Foo"
})
|> sync(
skills: [
location: ".claude/skills",
build: [
"use-foo": [
usage_rules: [:foo],
description: "Short description."
]
]
]
)
|> assert_creates(".claude/skills/use-foo/SKILL.md")

content = file_content(igniter, ".claude/skills/use-foo/SKILL.md")
assert content =~ "Short description."
refute content =~ "..."
end
end
end
Loading