From 6f2afc3a12a43b8cd0bc346c1b538a7e498bd851 Mon Sep 17 00:00:00 2001 From: Florian Kapfenberger Date: Sat, 4 Apr 2026 23:00:19 +0200 Subject: [PATCH 1/2] fix: normalize deps skill names per agentskills.io spec --- lib/mix/tasks/usage_rules.sync.ex | 77 ++++++++++- test/mix/tasks/usage_rules.sync_test.exs | 163 +++++++++++++++++++++-- 2 files changed, 229 insertions(+), 11 deletions(-) diff --git a/lib/mix/tasks/usage_rules.sync.ex b/lib/mix/tasks/usage_rules.sync.ex index 1fd86bb..f680123 100644 --- a/lib/mix/tasks/usage_rules.sync.ex +++ b/lib/mix/tasks/usage_rules.sync.ex @@ -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 @@ -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] @@ -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) @@ -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) @@ -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) diff --git a/test/mix/tasks/usage_rules.sync_test.exs b/test/mix/tasks/usage_rules.sync_test.exs index 43b2519..3d5b678 100644 --- a/test/mix/tasks/usage_rules.sync_test.exs +++ b/test/mix/tasks/usage_rules.sync_test.exs @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -1704,4 +1704,149 @@ 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 "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 From 057719bf06d4ede17b63e5d464975005c5f666f9 Mon Sep 17 00:00:00 2001 From: Florian Kapfenberger Date: Sun, 5 Apr 2026 16:48:28 +0200 Subject: [PATCH 2/2] test: add test that shows that old skills with old name get removed --- test/mix/tasks/usage_rules.sync_test.exs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/mix/tasks/usage_rules.sync_test.exs b/test/mix/tasks/usage_rules.sync_test.exs index 3d5b678..6862fb4 100644 --- a/test/mix/tasks/usage_rules.sync_test.exs +++ b/test/mix/tasks/usage_rules.sync_test.exs @@ -1718,6 +1718,35 @@ defmodule Mix.Tasks.UsageRules.SyncTest do 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 + --- + + + Old content. + + """ + + 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"