Packages
A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies
Current section
42 Versions
Jump to
Current section
42 Versions
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…