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
5
files changed
+255
additions
-31
deletions
| @@ -5,6 +5,15 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline | |
| 5 5 | |
| 6 6 | <!-- changelog --> |
| 7 7 | |
| 8 | + ## v0.1.4 (2025-06-06) |
| 9 | + |
| 10 | + |
| 11 | + |
| 12 | + |
| 13 | + ### Improvements: |
| 14 | + |
| 15 | + * add `--link-to-folder` option |
| 16 | + |
| 8 17 | ## v0.1.3 (2025-05-24) |
| @@ -4,10 +4,16 @@ | |
| 4 4 | |
| 5 5 | You'll note this package itself doesn't have a usage-rules.md. Its a simple tool that likely would not benefit from having a usage-rules.md file. |
| 6 6 | |
| 7 | + `usage-rules.md` is not an existing standard, rather it is a community initiative that may evolve over time as adoption grows and feedback is gathered. We encourage experimentation and welcome input on how to make this approach more useful for the broader Elixir ecosystem. |
| 8 | + |
| 7 9 | ## For Package Authors |
| 8 10 | |
| 9 11 | Even if you don't want to use LLMs, its very possible that your users will, and they will often come to you with hallucinations from their LLMs and try to get your help with it. Writing a `usage-rules.md` file is a great way to stop this sort of thing 😁 |
| 10 12 | |
| 13 | + We don't really know what makes great usage-rules.md files yet. Ash Framework is experimenting with quite fleshed out usage rules which seems to be working quite well. See [Ash Framework's usage-rules.md](https://github.com/ash-project/ash/blob/main/usage-rules.md) for one such large example. Perhaps for your package or framework only a few lines are necessary. We will all have to adjust over time. |
| 14 | + |
| 15 | + One quick tip is to have an agent begin the work of writing rules for you, by pointing it at your docs and asking it to write a `usage-rules.md` file in a condensed format that would be useful for agents to work with your tool. Then, aggressively prune and edit it to your taste. |
| 16 | + |
| 11 17 | ## Key Features |
| 12 18 | |
| 13 19 | 1. **Dependency Rules Collection**: Automatically discovers and collects usage rules from dependencies that provide `usage-rules.md` files in their package directory |
| @@ -6,7 +6,7 @@ | |
| 6 6 | {<<"GitHub">>,<<"https://github.com/ash-project/usage_rules">>}, |
| 7 7 | {<<"Website">>,<<"https://ash-hq.org">>}]}. |
| 8 8 | {<<"name">>,<<"usage_rules">>}. |
| 9 | - {<<"version">>,<<"0.1.3">>}. |
| 9 | + {<<"version">>,<<"0.1.4">>}. |
| 10 10 | {<<"description">>, |
| 11 11 | <<"A dev tool for Elixir projects to gather LLM usage rules from dependencies">>}. |
| 12 12 | {<<"elixir">>,<<"~> 1.18">>}. |
| @@ -22,6 +22,6 @@ | |
| 22 22 | [[{<<"name">>,<<"igniter">>}, |
| 23 23 | {<<"app">>,<<"igniter">>}, |
| 24 24 | {<<"optional">>,true}, |
| 25 | - {<<"requirement">>,<<"~> 0.6 and >= 0.6.2">>}, |
| 25 | + {<<"requirement">>,<<"~> 0.6 and >= 0.6.6">>}, |
| 26 26 | {<<"repository">>,<<"hexpm">>}]]}. |
| 27 27 | {<<"build_tools">>,[<<"mix">>]}. |
| @@ -21,6 +21,7 @@ defmodule Mix.Tasks.UsageRules.Sync.Docs do | |
| 21 21 | * `--all` - Gather usage rules from all dependencies that have them |
| 22 22 | * `--list` - List all dependencies with usage rules. If a file is provided, shows status (present, missing, stale) |
| 23 23 | * `--remove` - Remove specified packages from the target file instead of adding them |
| 24 | + * `--link-to-folder <folder>` - Save usage rules for each package in separate files within the specified folder and create CLAUDE.md style links |
| 24 25 | |
| 25 26 | ## Examples |
| 26 27 | |
| @@ -48,6 +49,26 @@ defmodule Mix.Tasks.UsageRules.Sync.Docs do | |
| 48 49 | ```sh |
| 49 50 | mix usage_rules.sync rules.md ash phoenix --remove |
| 50 51 | ``` |
| 52 | + |
| 53 | + Save usage rules to individual files in a folder with links to individual files: |
| 54 | + ```sh |
| 55 | + mix usage_rules.sync rules.md ash phoenix --link-to-folder rules |
| 56 | + ``` |
| 57 | + |
| 58 | + Combine all dependencies with folder links with links to individual files: |
| 59 | + ```sh |
| 60 | + mix usage_rules.sync rules.md --all --link-to-folder docs |
| 61 | + ``` |
| 62 | + |
| 63 | + Check status of packages using folder links: |
| 64 | + ```sh |
| 65 | + mix usage_rules.sync rules.md --list --link-to-folder rules |
| 66 | + ``` |
| 67 | + |
| 68 | + Remove packages and their folder files: |
| 69 | + ```sh |
| 70 | + mix usage_rules.sync rules.md ash phoenix --remove --link-to-folder rules |
| 71 | + ``` |
| 51 72 | """ |
| 52 73 | end |
| 53 74 | end |
| @@ -74,7 +95,8 @@ if Code.ensure_loaded?(Igniter) do | |
| 74 95 | schema: [ |
| 75 96 | all: :boolean, |
| 76 97 | list: :boolean, |
| 77 | - remove: :boolean |
| 98 | + remove: :boolean, |
| 99 | + link_to_folder: :string |
| 78 100 | ] |
| 79 101 | } |
| 80 102 | end |
| @@ -112,6 +134,7 @@ if Code.ensure_loaded?(Igniter) do | |
| 112 134 | all_option = igniter.args.options[:all] |
| 113 135 | list_option = igniter.args.options[:list] |
| 114 136 | remove_option = igniter.args.options[:remove] |
| 137 | + link_to_folder = igniter.args.options[:link_to_folder] |
| 115 138 | provided_packages = igniter.args.positional.packages |
| 116 139 | |
| 117 140 | cond do |
| @@ -131,29 +154,33 @@ if Code.ensure_loaded?(Igniter) do | |
| 131 154 | (all_option || list_option) && !Enum.empty?(provided_packages) -> |
| 132 155 | Igniter.add_issue(igniter, "Cannot specify packages when using --all or --list options") |
| 133 156 | |
| 134 | - # If no packages are given and neither --list nor --all nor --remove is set, add error |
| 135 | - Enum.empty?(provided_packages) && !all_option && !list_option && !remove_option -> |
| 136 | - add_usage_error(igniter) |
| 137 | - |
| 138 157 | # If --all is used without a file, add error |
| 139 158 | all_option && is_nil(igniter.args.positional[:file]) -> |
| 140 159 | Igniter.add_issue(igniter, "--all option requires a file to write to") |
| 141 160 | |
| 161 | + # If --link-to-folder is used without a file, add error |
| 162 | + link_to_folder && is_nil(igniter.args.positional[:file]) -> |
| 163 | + Igniter.add_issue(igniter, "--link-to-folder option requires a file to write to") |
| 164 | + |
| 165 | + # If no packages are given and neither --list nor --all nor --remove is set, add error |
| 166 | + Enum.empty?(provided_packages) && !all_option && !list_option && !remove_option -> |
| 167 | + add_usage_error(igniter) |
| 168 | + |
| 142 169 | # Handle --remove option |
| 143 170 | remove_option -> |
| 144 | - handle_remove_packages(igniter, provided_packages) |
| 171 | + handle_remove_packages(igniter, provided_packages, link_to_folder) |
| 145 172 | |
| 146 173 | # Handle --all option |
| 147 174 | all_option -> |
| 148 | - handle_all_option(igniter, all_deps) |
| 175 | + handle_all_option(igniter, all_deps, link_to_folder) |
| 149 176 | |
| 150 177 | # Handle --list option |
| 151 178 | list_option -> |
| 152 | - handle_list_option(igniter, all_deps) |
| 179 | + handle_list_option(igniter, all_deps, link_to_folder) |
| 153 180 | |
| 154 181 | # Handle specific packages |
| 155 182 | true -> |
| 156 | - handle_specific_packages(igniter, all_deps, provided_packages) |
| 183 | + handle_specific_packages(igniter, all_deps, provided_packages, link_to_folder) |
| 157 184 | end |
| 158 185 | end |
| 159 186 | |
| @@ -196,10 +223,19 @@ if Code.ensure_loaded?(Igniter) do | |
| 196 223 | |
| 197 224 | mix usage_rules.sync <file> <packages...> --remove |
| 198 225 | Remove specific packages from the target file |
| 226 | + |
| 227 | + mix usage_rules.sync <file> <packages...> --link-to-folder <folder> |
| 228 | + Save usage rules for each package in separate files within the specified folder and create CLAUDE.md style links |
| 229 | + |
| 230 | + mix usage_rules.sync <file> --list --link-to-folder <folder> |
| 231 | + List packages with usage rules and check status against folder links |
| 232 | + |
| 233 | + mix usage_rules.sync <file> <packages...> --remove --link-to-folder <folder> |
| 234 | + Remove specific packages from the target file and delete their folder files |
| 199 235 | """) |
| 200 236 | end |
| 201 237 | |
| 202 | - defp handle_all_option(igniter, all_deps) do |
| 238 | + defp handle_all_option(igniter, all_deps, link_to_folder) do |
| 203 239 | all_packages_with_rules = get_packages_with_usage_rules(igniter, all_deps) |
| 204 240 | |
| 205 241 | igniter |
| @@ -211,10 +247,10 @@ if Code.ensure_loaded?(Igniter) do | |
| 211 247 | Igniter.add_notice(acc, "Including usage rules for: #{name}") |
| 212 248 | end) |
| 213 249 | end) |
| 214 | - |> generate_usage_rules_file(all_packages_with_rules) |
| 250 | + |> generate_usage_rules_file(all_packages_with_rules, link_to_folder) |
| 215 251 | end |
| 216 252 | |
| 217 | - defp handle_list_option(igniter, all_deps) do |
| 253 | + defp handle_list_option(igniter, all_deps, link_to_folder) do |
| 218 254 | packages_with_rules = get_packages_with_usage_rules(igniter, all_deps) |
| 219 255 | |
| 220 256 | if Enum.empty?(packages_with_rules) do |
| @@ -223,14 +259,19 @@ if Code.ensure_loaded?(Igniter) do | |
| 223 259 | file_path = igniter.args.positional[:file] |
| 224 260 | |
| 225 261 | if file_path do |
| 226 | - list_packages_with_file_comparison(igniter, packages_with_rules, file_path) |
| 262 | + list_packages_with_file_comparison( |
| 263 | + igniter, |
| 264 | + packages_with_rules, |
| 265 | + file_path, |
| 266 | + link_to_folder |
| 267 | + ) |
| 227 268 | else |
| 228 269 | list_packages_without_comparison(igniter, packages_with_rules) |
| 229 270 | end |
| 230 271 | end |
| 231 272 | end |
| 232 273 | |
| 233 | - defp handle_specific_packages(igniter, all_deps, provided_packages) do |
| 274 | + defp handle_specific_packages(igniter, all_deps, provided_packages, link_to_folder) do |
| 234 275 | packages = |
| 235 276 | all_deps |
| 236 277 | |> Enum.filter(fn {name, _path} -> |
| @@ -246,14 +287,14 @@ if Code.ensure_loaded?(Igniter) do | |
| 246 287 | end |
| 247 288 | end) |
| 248 289 | |
| 249 | - generate_usage_rules_file(igniter, packages) |
| 290 | + generate_usage_rules_file(igniter, packages, link_to_folder) |
| 250 291 | end |
| 251 292 | |
| 252 | - defp handle_remove_packages(igniter, provided_packages) do |
| 293 | + defp handle_remove_packages(igniter, provided_packages, link_to_folder) do |
| 253 294 | file_path = igniter.args.positional[:file] |
| 254 295 | |
| 255 296 | if Igniter.exists?(igniter, file_path) do |
| 256 | - remove_packages_from_file(igniter, file_path, provided_packages) |
| 297 | + remove_packages_from_file(igniter, file_path, provided_packages, link_to_folder) |
| 257 298 | else |
| 258 299 | Igniter.add_issue(igniter, "File #{file_path} does not exist") |
| 259 300 | end |
| @@ -267,21 +308,33 @@ if Code.ensure_loaded?(Igniter) do | |
| 267 308 | end) |
| 268 309 | end |
| 269 310 | |
| 270 | - defp list_packages_with_file_comparison(igniter, packages_with_rules, file_path) do |
| 311 | + defp list_packages_with_file_comparison( |
| 312 | + igniter, |
| 313 | + packages_with_rules, |
| 314 | + file_path, |
| 315 | + link_to_folder |
| 316 | + ) do |
| 271 317 | current_file_content = read_current_file_content(igniter, file_path) |
| 272 318 | |
| 273 319 | Enum.reduce(packages_with_rules, igniter, fn {name, path}, acc -> |
| 274 320 | usage_rules_path = Path.join(path, "usage-rules.md") |
| 275 321 | |
| 276 322 | package_rules_content = |
| 277 | - case Rewrite.source(igniter.rewrite, usage_rules_path) do |
| 323 | + case Rewrite.source(acc.rewrite, usage_rules_path) do |
| 278 324 | {:ok, source} -> Rewrite.Source.get(source, :content) |
| 279 325 | {:error, _} -> File.read!(usage_rules_path) |
| 280 326 | end |
| 281 327 | |
| 282 | - status = get_package_status_in_file(name, package_rules_content, current_file_content) |
| 283 | - colored_status = colorize_status(status) |
| 284 | - Igniter.add_notice(acc, "#{name}: #{colored_status}") |
| 328 | + status = |
| 329 | + get_package_status_in_file( |
| 330 | + acc, |
| 331 | + name, |
| 332 | + package_rules_content, |
| 333 | + current_file_content, |
| 334 | + link_to_folder |
| 335 | + ) |
| 336 | + |
| 337 | + Igniter.add_notice(acc, "#{name}: #{colorize_status(status)}") |
| 285 338 | end) |
| 286 339 | end |
| 287 340 | |
| @@ -308,7 +361,15 @@ if Code.ensure_loaded?(Igniter) do | |
| 308 361 | end |
| 309 362 | end |
| 310 363 | |
| 311 | - defp generate_usage_rules_file(igniter, packages) do |
| 364 | + defp generate_usage_rules_file(igniter, packages, link_to_folder) do |
| 365 | + if link_to_folder do |
| 366 | + generate_usage_rules_with_folder_links(igniter, packages, link_to_folder) |
| 367 | + else |
| 368 | + generate_usage_rules_inline(igniter, packages) |
| 369 | + end |
| 370 | + end |
| 371 | + |
| 372 | + defp generate_usage_rules_inline(igniter, packages) do |
| 312 373 | package_contents = |
| 313 374 | packages |
| 314 375 | |> Enum.map(fn {name, path} -> |
| @@ -381,7 +442,112 @@ if Code.ensure_loaded?(Igniter) do | |
| 381 442 | ) |
| 382 443 | end |
| 383 444 | |
| 384 | - defp remove_packages_from_file(igniter, file_path, packages_to_remove) do |
| 445 | + defp generate_usage_rules_with_folder_links(igniter, packages, folder_name) do |
| 446 | + # First, create individual files for each package in the folder |
| 447 | + igniter = |
| 448 | + Enum.reduce(packages, igniter, fn {name, path}, acc -> |
| 449 | + usage_rules_path = Path.join(path, "usage-rules.md") |
| 450 | + |
| 451 | + content = |
| 452 | + case Rewrite.source(acc.rewrite, usage_rules_path) do |
| 453 | + {:ok, source} -> Rewrite.Source.get(source, :content) |
| 454 | + {:error, _} -> File.read!(usage_rules_path) |
| 455 | + end |
| 456 | + |
| 457 | + package_file_path = Path.join(folder_name, "#{name}.md") |
| 458 | + |
| 459 | + Igniter.create_or_update_file( |
| 460 | + acc, |
| 461 | + package_file_path, |
| 462 | + content, |
| 463 | + fn source -> |
| 464 | + Rewrite.Source.update(source, :content, content) |
| 465 | + end |
| 466 | + ) |
| 467 | + end) |
| 468 | + |
| 469 | + # Then, create the main file with links |
| 470 | + package_contents = |
| 471 | + packages |
| 472 | + |> Enum.map(fn {name, _path} -> |
| 473 | + {name, |
| 474 | + "<-- #{name}-start -->\n" <> |
| 475 | + "## #{name} usage\n" <> |
| 476 | + "@#{folder_name}/#{name}.md" <> |
| 477 | + "\n<-- #{name}-end -->"} |
| 478 | + end) |
| 479 | + |
| 480 | + package_rules_content = Enum.map_join(package_contents, "\n", &elem(&1, 1)) |
| 481 | + |
| 482 | + full_contents_for_new_file = |
| 483 | + "<-- usage-rules-start -->\n" <> |
| 484 | + package_rules_content <> |
| 485 | + "\n<-- usage-rules-end -->" |
| 486 | + |
| 487 | + Igniter.create_or_update_file( |
| 488 | + igniter, |
| 489 | + igniter.args.positional[:file], |
| 490 | + full_contents_for_new_file, |
| 491 | + fn source -> |
| 492 | + current_contents = Rewrite.Source.get(source, :content) |
| 493 | + |
| 494 | + new_content = |
| 495 | + case String.split(current_contents, [ |
| 496 | + "<-- usage-rules-start -->\n", |
| 497 | + "\n<-- usage-rules-end -->" |
| 498 | + ]) do |
| 499 | + [prelude, current_packages_contents, postlude] -> |
| 500 | + Enum.reduce(package_contents, current_packages_contents, fn {name, |
| 501 | + package_content}, |
| 502 | + acc -> |
| 503 | + case String.split(acc, [ |
| 504 | + "<-- #{name}-start -->\n", |
| 505 | + "\n<-- #{name}-end -->" |
| 506 | + ]) do |
| 507 | + [prelude, _, postlude] -> |
| 508 | + prelude <> package_content <> postlude |
| 509 | + |
| 510 | + _ -> |
| 511 | + acc <> "\n" <> package_content |
| 512 | + end |
| 513 | + end) |
| 514 | + |> then(fn content -> |
| 515 | + prelude <> |
| 516 | + "<-- usage-rules-start -->\n" <> |
| 517 | + content <> |
| 518 | + "\n<-- usage-rules-end -->" <> |
| 519 | + postlude |
| 520 | + end) |
| 521 | + |
| 522 | + _ -> |
| 523 | + current_contents <> |
| 524 | + "\n<-- usage-rules-start -->\n" <> |
| 525 | + package_rules_content <> |
| 526 | + "\n<-- usage-rules-end -->\n" |
| 527 | + end |
| 528 | + |
| 529 | + Rewrite.Source.update(source, :content, new_content) |
| 530 | + end |
| 531 | + ) |
| 532 | + end |
| 533 | + |
| 534 | + defp remove_packages_from_file(igniter, file_path, packages_to_remove, link_to_folder) do |
| 535 | + # If using link-to-folder, also remove the individual package files |
| 536 | + igniter = |
| 537 | + if link_to_folder do |
| 538 | + Enum.reduce(packages_to_remove, igniter, fn package_name, acc -> |
| 539 | + package_file_path = Path.join(link_to_folder, "#{package_name}.md") |
| 540 | + |
| 541 | + if Igniter.exists?(acc, package_file_path) do |
| 542 | + Igniter.rm(acc, package_file_path) |
| 543 | + else |
| 544 | + acc |
| 545 | + end |
| 546 | + end) |
| 547 | + else |
| 548 | + igniter |
| 549 | + end |
| 550 | + |
| 385 551 | Igniter.update_file(igniter, file_path, fn source -> |
| 386 552 | current_contents = Rewrite.Source.get(source, :content) |
| 387 553 | |
| @@ -452,17 +618,33 @@ if Code.ensure_loaded?(Igniter) do | |
| 452 618 | end |
| 453 619 | end |
| 454 620 | |
| 455 | - defp get_package_status_in_file(name, package_rules_content, file_content) do |
| 621 | + defp get_package_status_in_file( |
| 622 | + igniter, |
| 623 | + name, |
| 624 | + package_rules_content, |
| 625 | + file_content, |
| 626 | + link_to_folder |
| 627 | + ) do |
| 456 628 | package_start_marker = "<-- #{name}-start -->" |
| 457 629 | package_end_marker = "<-- #{name}-end -->" |
| 458 630 | |
| 459 631 | case String.split(file_content, [package_start_marker, package_end_marker]) do |
| 460 632 | [_, current_package_content, _] -> |
| 461 633 | # Package is present in file, check if content matches |
| 462 | - expected_content = "\n## #{name} usage\n" <> package_rules_content <> "\n" |
| 634 | + expected_content = |
| 635 | + if link_to_folder do |
| 636 | + "\n## #{name} usage\n@#{link_to_folder}/#{name}.md\n" |
| 637 | + else |
| 638 | + "\n## #{name} usage\n" <> package_rules_content <> "\n" |
| 639 | + end |
| 463 640 | |
| 464 641 | if String.trim(current_package_content) == String.trim(expected_content) do |
| 465 | - "present" |
| 642 | + # If using link-to-folder, also check the linked file exists and matches |
| 643 | + if link_to_folder do |
| 644 | + check_linked_file_status(igniter, name, package_rules_content, link_to_folder) |
| 645 | + else |
| 646 | + "present" |
| 647 | + end |
| 466 648 | else |
| 467 649 | "stale" |
| 468 650 | end |
| @@ -473,6 +655,33 @@ if Code.ensure_loaded?(Igniter) do | |
| 473 655 | end |
| 474 656 | end |
| 475 657 | |
| 658 | + defp check_linked_file_status(igniter, name, expected_content, link_to_folder) do |
| 659 | + linked_file_path = Path.join(link_to_folder, "#{name}.md") |
| 660 | + |
| 661 | + if Igniter.exists?(igniter, linked_file_path) do |
| 662 | + actual_content = |
| 663 | + case Rewrite.source(igniter.rewrite, linked_file_path) do |
| 664 | + {:ok, source} -> |
| 665 | + Rewrite.Source.get(source, :content) |
| 666 | + |
| 667 | + {:error, _} -> |
| 668 | + if File.exists?(linked_file_path) do |
| 669 | + File.read!(linked_file_path) |
| 670 | + else |
| 671 | + "" |
| 672 | + end |
| 673 | + end |
| 674 | + |
| 675 | + if String.trim(actual_content) == String.trim(expected_content) do |
| 676 | + "present" |
| 677 | + else |
| 678 | + "stale" |
| 679 | + end |
| 680 | + else |
| 681 | + "stale" |
| 682 | + end |
| 683 | + end |
| 684 | + |
| 476 685 | defp colorize_status("present"), do: "#{IO.ANSI.green()}present#{IO.ANSI.reset()}" |
| 477 686 | defp colorize_status("stale"), do: "#{IO.ANSI.yellow()}stale#{IO.ANSI.reset()}" |
| 478 687 | defp colorize_status("missing"), do: "#{IO.ANSI.red()}missing#{IO.ANSI.reset()}" |
| @@ -1,7 +1,7 @@ | |
| 1 1 | defmodule UsageRules.MixProject do |
| 2 2 | use Mix.Project |
| 3 3 | |
| 4 | - @version "0.1.3" |
| 4 | + @version "0.1.4" |
| 5 5 | @description """ |
| 6 6 | A dev tool for Elixir projects to gather LLM usage rules from dependencies |
| 7 7 | """ |
| @@ -83,7 +83,7 @@ defmodule UsageRules.MixProject do | |
| 83 83 | |
| 84 84 | defp deps do |
| 85 85 | [ |
| 86 | - {:igniter, "~> 0.6 and >= 0.6.2", optional: true}, |
| 86 | + {:igniter, "~> 0.6 and >= 0.6.6", optional: true}, |
| 87 87 | # dev dependencies |
| 88 88 | {:ex_doc, "~> 0.37-rc", only: [:dev, :test], runtime: false}, |
| 89 89 | {:ex_check, "~> 0.12", only: [:dev, :test]}, |