Current section

42 Versions

Jump to

Compare versions

6 files changed
+164 additions
-21 deletions
  @@ -12,6 +12,21 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
12 12
13 13 <!-- changelog -->
14 14
15 + ## v1.2.6 (2026-04-13)
16 +
17 +
18 +
19 +
20 + ### Bug Fixes:
21 +
22 + * only generate reference links for deps with actual content by Zach Daniel
23 +
24 + * normalize deps skill names per agentskills.io spec (#68) by Florian Kapfenberger
25 +
26 + * documentation retrieval for callbacks (#60) (#60) by Iulian Costan
27 +
28 + * filter out deps without usage rules in skills.build (#64) by Adam Royle
29 +
15 30 ## v1.2.5 (2026-03-09)
  @@ -57,8 +57,9 @@ defp usage_rules do
57 57 [
58 58 file: "CLAUDE.md",
59 59 # rules to include directly in CLAUDE.md
60 + # :usage_rules itself provides rules for search_docs, docs, etc.
60 61 # use a regex to match multiple deps, or atoms/strings for specific ones
61 - usage_rules: [:ash, ~r/^ash_/],
62 + usage_rules: [:usage_rules, :ash, ~r/^ash_/],
62 63 # If your CLAUDE.md is getting too big, link instead of inlining:
63 64 usage_rules: [:ash, {~r/^ash_/, link: :markdown}],
64 65 # or use skills
  @@ -7,7 +7,7 @@
7 7 {<<"GitHub">>,<<"https://github.com/ash-project/usage_rules">>},
8 8 {<<"Website">>,<<"https://ash-hq.org">>}]}.
9 9 {<<"name">>,<<"usage_rules">>}.
10 - {<<"version">>,<<"1.2.5">>}.
10 + {<<"version">>,<<"1.2.6">>}.
11 11 {<<"description">>,
12 12 <<"A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies">>}.
13 13 {<<"elixir">>,<<"~> 1.18">>}.
  @@ -63,7 +63,29 @@ defmodule Mix.Tasks.UsageRules.Docs do
63 63 quote do
64 64 require IEx.Helpers
65 65
66 - IEx.Helpers.h(unquote(quoted))
66 + original_gl = Process.group_leader()
67 + {:ok, cap} = StringIO.open("")
68 + Process.group_leader(self(), cap)
69 +
70 + try do
71 + IEx.Helpers.h(unquote(quoted))
72 + {_, output} = StringIO.contents(cap)
73 +
74 + # Use regex with case insensitivity to detect the hint about callbacks
75 + if String.match?(
76 + output,
77 + ~r/No documentation for function #{Regex.escape(unquote(module))} was found,.*callback.*same name/i
78 + ) do
79 + Process.group_leader(self(), original_gl)
80 + IEx.Helpers.b(unquote(quoted))
81 + else
82 + Process.group_leader(self(), original_gl)
83 + IO.write(output)
84 + end
85 + after
86 + Process.group_leader(self(), original_gl)
87 + StringIO.close(cap)
88 + end
67 89 end
68 90 )
69 91 end
  @@ -265,7 +265,7 @@ if Code.ensure_loaded?(Igniter) do
265 265 Igniter.exists?(igniter, Path.join(path, "usage-rules.md"))
266 266 end)
267 267 |> Enum.map(fn {pkg_name, _path, _mode} ->
268 - {:"use-#{pkg_name}", [usage_rules: [pkg_name]]}
268 + {:"use-#{normalize_skill_name(pkg_name)}", [usage_rules: [pkg_name]]}
269 269 end)
270 270
271 271 # Merge explicit build specs on top of package-derived ones
  @@ -333,7 +333,8 @@ if Code.ensure_loaded?(Igniter) do
333 333 if igniter.assigns[:test_mode?] do
334 334 igniter.rewrite.sources
335 335 |> Enum.filter(fn {path, _source} ->
336 - String.match?(path, ~r|^deps/[^/]+/usage-rules\.md$|) ||
336 + String.match?(path, ~r|^deps/[^/]+/mix\.exs$|) ||
337 + String.match?(path, ~r|^deps/[^/]+/usage-rules\.md$|) ||
337 338 String.match?(path, ~r|^deps/[^/]+/usage-rules/[^/]+\.md$|) ||
338 339 String.match?(path, ~r|^deps/[^/]+/usage-rules/skills/[^/]+/SKILL\.md$|) ||
339 340 String.match?(path, ~r|^deps/[^/]+/usage-rules/skills/[^/]+/.+$|)
  @@ -353,14 +354,18 @@ if Code.ensure_loaded?(Igniter) do
353 354 defp get_packages_with_usage_rules(igniter, all_deps) do
354 355 Enum.filter(all_deps, fn
355 356 {_name, path} when is_binary(path) and path != "" ->
356 - Igniter.exists?(igniter, Path.join(path, "usage-rules.md")) ||
357 - Igniter.exists?(igniter, Path.join(path, "usage-rules"))
357 + package_has_usage_rules?(igniter, path)
358 358
359 359 _ ->
360 360 false
361 361 end)
362 362 end
363 363
364 + defp package_has_usage_rules?(igniter, package_path) do
365 + Igniter.exists?(igniter, Path.join(package_path, "usage-rules.md")) ||
366 + Enum.any?(find_available_sub_rules(igniter, package_path))
367 + end
368 +
364 369 # -------------------------------------------------------------------
365 370 # Config resolution
366 371 # -------------------------------------------------------------------
  @@ -796,12 +801,19 @@ if Code.ensure_loaded?(Igniter) do
796 801
797 802 defp build_single_skill(igniter, all_deps, skill_name, skill_opts, skills_location) do
798 803 skill_name = to_string(skill_name)
804 + igniter = warn_on_invalid_skill_name(igniter, skill_name)
805 +
799 806 skill_dir = Path.join(skills_location, skill_name)
800 807 usage_rule_specs = skill_opts[:usage_rules] || []
801 808 custom_description = skill_opts[:description]
802 809
803 810 # Resolve which packages to include in this skill (supports atoms and regexes)
804 - resolved_packages = expand_dep_specs(usage_rule_specs, all_deps)
811 + all_expanded = expand_dep_specs(usage_rule_specs, all_deps)
812 +
813 + resolved_packages =
814 + Enum.filter(all_expanded, fn {_pkg_name, package_path, _mode} ->
815 + package_has_usage_rules?(igniter, package_path)
816 + end)
805 817
806 818 if Enum.any?(resolved_packages) do
807 819 generate_built_skill(
  @@ -809,6 +821,7 @@ if Code.ensure_loaded?(Igniter) do
809 821 skill_name,
810 822 skill_dir,
811 823 resolved_packages,
824 + all_expanded,
812 825 custom_description
813 826 )
814 827 else
  @@ -821,10 +834,11 @@ if Code.ensure_loaded?(Igniter) do
821 834 skill_name,
822 835 skill_dir,
823 836 resolved_packages,
837 + all_expanded,
824 838 custom_description
825 839 ) do
826 840 skill_md =
827 - build_skill_md(igniter, skill_name, resolved_packages, custom_description)
841 + build_skill_md(igniter, skill_name, resolved_packages, all_expanded, custom_description)
828 842
829 843 igniter =
830 844 Igniter.create_or_update_file(
  @@ -874,8 +888,10 @@ if Code.ensure_loaded?(Igniter) do
874 888 end)
875 889 end
876 890
877 - defp build_skill_md(igniter, skill_name, resolved_packages, custom_description) do
878 - description = custom_description || build_skill_description(skill_name, resolved_packages)
891 + defp build_skill_md(igniter, skill_name, resolved_packages, all_expanded, custom_description) do
892 + description =
893 + (custom_description || build_skill_description(skill_name, resolved_packages))
894 + |> truncate_description()
879 895
880 896 formatted_description = format_yaml_string(description)
881 897
  @@ -890,7 +906,7 @@ if Code.ensure_loaded?(Igniter) do
890 906 """
891 907 |> String.trim_trailing()
892 908
893 - body = build_skill_body(igniter, skill_name, resolved_packages)
909 + body = build_skill_body(igniter, skill_name, resolved_packages, all_expanded)
894 910
895 911 frontmatter <>
896 912 "\n\n" <>
  @@ -915,18 +931,23 @@ if Code.ensure_loaded?(Igniter) do
915 931 end
916 932 end
917 933
918 - defp build_skill_body(igniter, _skill_name, resolved_packages) do
934 + defp build_skill_body(igniter, _skill_name, resolved_packages, all_expanded) do
919 935 sections = []
920 936
921 - # Sub-rules as references
937 + # Sub-rules as references (only from packages with usage rules)
922 938 all_sub_rules =
923 939 Enum.flat_map(resolved_packages, fn {_pkg_name, package_path, _mode} ->
924 940 find_available_sub_rules(igniter, package_path)
925 941 end)
926 942
927 - # All packages are references
943 + # Only include main rule links for packages that have a main usage-rules.md
944 + # (a package may pass the filter via sub-rules alone, with no main file)
928 945 all_main_rules =
929 - Enum.map(resolved_packages, fn {pkg_name, _path, _mode} -> pkg_name end)
946 + resolved_packages
947 + |> Enum.filter(fn {_pkg_name, package_path, _mode} ->
948 + read_dep_content(igniter, Path.join(package_path, "usage-rules.md")) != ""
949 + end)
950 + |> Enum.map(fn {pkg_name, _path, _mode} -> pkg_name end)
930 951
931 952 all_references =
932 953 Enum.map(all_sub_rules, fn sub_rule ->
  @@ -946,8 +967,8 @@ if Code.ensure_loaded?(Igniter) do
946 967 sections
947 968 end
948 969
949 - # Search docs for all packages
950 - package_names = Enum.map(resolved_packages, &elem(&1, 0))
970 + # Search docs for all matched packages (hexdocs exist regardless of usage-rules)
971 + package_names = Enum.map(all_expanded, &elem(&1, 0))
951 972
952 973 search_flags = Enum.map_join(package_names, " ", &"-p #{&1}")
953 974
  @@ -964,9 +985,9 @@ if Code.ensure_loaded?(Igniter) do
964 985 |> String.trim_trailing()
965 986 ]
966 987
967 - # Mix tasks from all packages (at the bottom)
988 + # Mix tasks from all matched packages (at the bottom)
968 989 all_mix_tasks =
969 - Enum.flat_map(resolved_packages, fn {pkg_name, _path, _mode} ->
990 + Enum.flat_map(all_expanded, fn {pkg_name, _path, _mode} ->
970 991 discover_mix_tasks(pkg_name)
971 992 |> Enum.map(fn {task, doc} -> {pkg_name, task, doc} end)
972 993 end)
  @@ -1007,6 +1028,8 @@ if Code.ensure_loaded?(Igniter) do
1007 1028 skill_dirs = find_package_skill_dirs(acc, pkg_path)
1008 1029
1009 1030 Enum.reduce(skill_dirs, acc, fn skill_name, inner_acc ->
1031 + inner_acc = warn_on_invalid_skill_name(inner_acc, skill_name)
1032 +
1010 1033 src_skill_dir = Path.join([pkg_path, "usage-rules", "skills", skill_name])
1011 1034 dst_skill_dir = Path.join(skills_location, skill_name)
1012 1035
  @@ -1430,6 +1453,88 @@ if Code.ensure_loaded?(Igniter) do
1430 1453 end
1431 1454 end
1432 1455
1456 + # Normalizes a skill name to comply with the agentskills.io specification:
1457 + # https://agentskills.io/specification#name-field
1458 + #
1459 + # - Lowercases the name
1460 + # - Replaces underscores with hyphens
1461 + # - Strips invalid characters (only a-z, 0-9, hyphens allowed)
1462 + # - Collapses consecutive hyphens
1463 + # - Trims leading/trailing hyphens
1464 + # - Truncates to 64 characters
1465 + defp normalize_skill_name(name) do
1466 + name
1467 + |> to_string()
1468 + |> String.downcase()
1469 + |> String.replace(~r/[_.\s]/, "-")
1470 + |> String.replace(~r/[^a-z0-9-]/, "")
1471 + |> String.replace(~r/-{2,}/, "-")
1472 + |> String.trim("-")
1473 + |> String.slice(0, 64)
1474 + end
1475 +
1476 + defp warn_on_invalid_skill_name(igniter, name) do
1477 + case validate_skill_name(name) do
1478 + :ok -> igniter
1479 + {:error, message} -> Igniter.add_warning(igniter, message)
1480 + end
1481 + end
1482 +
1483 + @skill_name_pattern ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
1484 +
1485 + # Validates a skill name against the agentskills.io specification:
1486 + # https://agentskills.io/specification#name-field
1487 + defp validate_skill_name(name) when byte_size(name) > 64 do
1488 + {:error, "Skill name '#{name}' exceeds 64 characters (agentskills.io spec requires ≤ 64)"}
1489 + end
1490 +
1491 + defp validate_skill_name("-" <> _ = name) do
1492 + {:error, "Skill name '#{name}' must not start or end with a hyphen (agentskills.io spec)"}
1493 + end
1494 +
1495 + defp validate_skill_name(name) do
1496 + cond do
1497 + String.ends_with?(name, "-") ->
1498 + {:error,
1499 + "Skill name '#{name}' must not start or end with a hyphen (agentskills.io spec)"}
1500 +
1501 + String.contains?(name, "--") ->
1502 + {:error,
1503 + "Skill name '#{name}' must not contain consecutive hyphens (agentskills.io spec)"}
1504 +
1505 + not Regex.match?(@skill_name_pattern, name) ->
1506 + {:error,
1507 + "Skill name '#{name}' must only contain lowercase letters, numbers, and hyphens (agentskills.io spec)"}
1508 +
1509 + true ->
1510 + :ok
1511 + end
1512 + end
1513 +
1514 + # Truncates a description to the agentskills.io spec maximum of 1024 bytes.
1515 + defp truncate_description(description) do
1516 + if byte_size(description) > 1024 do
1517 + truncate_to_byte_size(description, 1021) <> "..."
1518 + else
1519 + description
1520 + end
1521 + end
1522 +
1523 + defp truncate_to_byte_size(string, max_bytes) do
1524 + string
1525 + |> String.graphemes()
1526 + |> Enum.reduce_while({"", 0}, fn grapheme, {acc, size} ->
1527 + new_size = size + byte_size(grapheme)
1528 +
1529 + if new_size > max_bytes do
1530 + {:halt, {acc, size}}
1531 + else
1532 + {:cont, {acc <> grapheme, new_size}}
1533 + end
1534 + end)
1535 + |> elem(0)
1536 + end
1537 +
1433 1538 defp format_yaml_string(str) do
1434 1539 str = String.trim(str)
Loading more files…