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
8
files changed
+457
additions
-10
deletions
| @@ -5,6 +5,21 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline | |
| 5 5 | |
| 6 6 | <!-- changelog --> |
| 7 7 | |
| 8 | + ## v0.1.18 (2025-07-17) |
| 9 | + |
| 10 | + |
| 11 | + |
| 12 | + |
| 13 | + ### Improvements: |
| 14 | + |
| 15 | + * add `usage_rules.search_docs` task by Zach Daniel |
| 16 | + |
| 17 | + * instruct agents to use the new task by Zach Daniel |
| 18 | + |
| 19 | + * explain about no early returns in usage rules by Zach Daniel |
| 20 | + |
| 21 | + * add a note on early returns not being a thing in usage-rules.md by Zach Daniel |
| 22 | + |
| 8 23 | ## v0.1.17 (2025-07-17) |
| @@ -1,6 +1,10 @@ | |
| 1 1 | # UsageRules |
| 2 2 | |
| 3 | - **UsageRules** is a development tool for Elixir projects that helps gather and consolidate usage rules from dependencies. The package provides Mix tasks to collect documentation from dependencies that have `usage-rules.md` files and combine them into a single rules file for your project. |
| 3 | + **UsageRules** is a development tool for Elixir projects that: |
| 4 | + |
| 5 | + - helps gather and consolidate usage rules from dependencies to provide to LLM agents via `mix usage_rules.sync` |
| 6 | + - provides pre-built usage rules for Elixir |
| 7 | + - provides a powerful documentation search task for hexdocs with `mix usage_rules.search_docs` |
| 4 8 | |
| 5 9 | ## Quickstart |
| 6 10 | |
| @@ -58,6 +62,7 @@ mix usage_rules.sync AGENTS.md package package:html package:database | |
| 58 62 | 2. **Rules Consolidation**: Combines multiple package rules into a single file with proper sectioning and markers |
| 59 63 | 3. **Status Tracking**: Can list dependencies with usage rules and check if your consolidated file is up-to-date |
| 60 64 | 4. **Selective Management**: Allows adding/removing specific packages from your rules file |
| 65 | + 5. **Documentation Search**: Search hexdocs with human-readable markdown output using `mix usage_rules.search_docs` - designed to help AI agents find relevant documentation |
| 61 66 | |
| 62 67 | ## How It Works |
| 63 68 | |
| @@ -143,6 +148,27 @@ mix usage_rules.sync CLAUDE.md ash phoenix --link-to-folder deps | |
| 143 148 | mix usage_rules.sync CLAUDE.md --all --link-to-folder docs |
| 144 149 | ``` |
| 145 150 | |
| 151 | + ### Documentation Search (`mix usage_rules.search_docs`) |
| 152 | + |
| 153 | + The `mix usage_rules.search_docs` task searches hexdocs with human-readable markdown output, specifically designed to help AI agents find relevant documentation. |
| 154 | + |
| 155 | + ```sh |
| 156 | + # Search documentation for all dependencies in the current mix project |
| 157 | + mix usage_rules.search_docs "search term" |
| 158 | + |
| 159 | + # Search documentation for specific packages |
| 160 | + mix usage_rules.search_docs "search term" -p ecto -p ash |
| 161 | + |
| 162 | + # Search documentation for specific versions |
| 163 | + mix usage_rules.search_docs "search term" -p ecto@3.13.2 -p ash@3.5.26 |
| 164 | + |
| 165 | + # Control output format and pagination |
| 166 | + mix usage_rules.search_docs "search term" --output json --page 2 --per-page 20 |
| 167 | + |
| 168 | + # Search across all packages on hex |
| 169 | + mix usage_rules.search_docs "search term" --everywhere |
| 170 | + ``` |
| 171 | + |
| 146 172 | ## Advanced Features |
| 147 173 | |
| 148 174 | ### Folder Links (`--link-to-folder`) |
| @@ -6,21 +6,32 @@ | |
| 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.17">>}. |
| 9 | + {<<"version">>,<<"0.1.18">>}. |
| 10 10 | {<<"description">>, |
| 11 11 | <<"A dev tool for Elixir projects to gather LLM usage rules from dependencies">>}. |
| 12 12 | {<<"elixir">>,<<"~> 1.18">>}. |
| 13 13 | {<<"files">>, |
| 14 14 | [<<"lib">>,<<"lib/mix">>,<<"lib/mix/tasks">>, |
| 15 | + <<"lib/mix/tasks/usage_rules.search_docs.ex">>, |
| 15 16 | <<"lib/mix/tasks/usage_rules.install.ex">>, |
| 16 17 | <<"lib/mix/tasks/usage_rules.sync.ex">>,<<"lib/usage_rules.ex">>, |
| 17 18 | <<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE">>, |
| 18 19 | <<"CHANGELOG.md">>,<<"usage-rules">>,<<"usage-rules/elixir.md">>, |
| 19 | - <<"usage-rules/otp.md">>]}. |
| 20 | + <<"usage-rules/otp.md">>,<<"usage-rules.md">>]}. |
| 20 21 | {<<"app">>,<<"usage_rules">>}. |
| 21 22 | {<<"licenses">>,[<<"MIT">>]}. |
| 22 23 | {<<"requirements">>, |
| 23 | - [[{<<"name">>,<<"igniter">>}, |
| 24 | + [[{<<"name">>,<<"req">>}, |
| 25 | + {<<"app">>,<<"req">>}, |
| 26 | + {<<"optional">>,false}, |
| 27 | + {<<"requirement">>,<<"~> 0.5">>}, |
| 28 | + {<<"repository">>,<<"hexpm">>}], |
| 29 | + [{<<"name">>,<<"jason">>}, |
| 30 | + {<<"app">>,<<"jason">>}, |
| 31 | + {<<"optional">>,false}, |
| 32 | + {<<"requirement">>,<<"~> 1.0">>}, |
| 33 | + {<<"repository">>,<<"hexpm">>}], |
| 34 | + [{<<"name">>,<<"igniter">>}, |
| 24 35 | {<<"app">>,<<"igniter">>}, |
| 25 36 | {<<"optional">>,true}, |
| 26 37 | {<<"requirement">>,<<"~> 0.6 and >= 0.6.6">>}, |
| @@ -50,15 +50,15 @@ if Code.ensure_loaded?(Igniter) do | |
| 50 50 | |
| 51 51 | Suggested starting point to sync all usage rules: |
| 52 52 | |
| 53 | - mix usage_rules.sync AGENTS.md --all \ |
| 54 | - --inline usage_rules:all \ |
| 53 | + mix usage_rules.sync AGENTS.md --all \\ |
| 54 | + --inline usage_rules:all \\ |
| 55 55 | --link-to-folder deps |
| 56 56 | |
| 57 57 | Or sync only a specific set, copying their rules to a |
| 58 58 | specific folder |
| 59 59 | |
| 60 | - mix usage_rules.sync AGENTS.md \ |
| 61 | - ash ash_postgres \ |
| 60 | + mix usage_rules.sync AGENTS.md \\ |
| 61 | + ash ash_postgres \\ |
| 62 62 | --link-to-folder rules |
| @@ -0,0 +1,369 @@ | |
| 1 | + defmodule Mix.Tasks.UsageRules.SearchDocs do |
| 2 | + use Mix.Task |
| 3 | + # This will eventually be replaced with something like `mix hex.docs.search` ideally |
| 4 | + |
| 5 | + @shortdoc "Searches hexdocs with human-readable output" |
| 6 | + |
| 7 | + @moduledoc """ |
| 8 | + Searches hexdocs with human-readable output (markdown by default). |
| 9 | + If no version is specified, defaults to version used in the current mix project. |
| 10 | + If called outside of a mix project or the dependency is not used in the |
| 11 | + current mix project, defaults to the latest version. |
| 12 | + ## Search documentation for all dependencies in the current mix project |
| 13 | + $ mix usage_rules.search_docs "search term" |
| 14 | + ## Search documentation for specific packages |
| 15 | + $ mix usage_rules.search_docs "search term" -p ecto -p ash |
| 16 | + ## Search documentation for specific versions |
| 17 | + $ mix usage_rules.search_docs "search term" -p ecto@3.13.2 -p ash@3.5.26 |
| 18 | + ## Control output format and pagination |
| 19 | + $ mix usage_rules.search_docs "search term" --output json --page 2 --per-page 20 |
| 20 | + ## Search across all packages on hex |
| 21 | + $ mix usage_rules.search_docs "search term" --everywhere |
| 22 | + """ |
| 23 | + |
| 24 | + @switches [ |
| 25 | + package: :keep, |
| 26 | + page: :integer, |
| 27 | + per_page: :integer, |
| 28 | + output: :string, |
| 29 | + everywhere: :boolean |
| 30 | + ] |
| 31 | + @aliases [p: :package, o: :output, e: :everywhere] |
| 32 | + |
| 33 | + require Logger |
| 34 | + |
| 35 | + @impl true |
| 36 | + def run(args) do |
| 37 | + {:ok, _} = Application.ensure_all_started(:req) |
| 38 | + {opts, args} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) |
| 39 | + opts = Keyword.put(opts, :mix_project, !!Mix.Project.get()) |
| 40 | + |
| 41 | + opts = |
| 42 | + if opts[:package] do |
| 43 | + Keyword.put(opts, :package, Keyword.get_values(opts, :package)) |
| 44 | + else |
| 45 | + opts |
| 46 | + end |
| 47 | + |
| 48 | + term = |
| 49 | + case args do |
| 50 | + ["-" <> _ | _] -> raise_bad_args!() |
| 51 | + [] -> raise_bad_args!() |
| 52 | + [term | _args] -> term |
| 53 | + end |
| 54 | + |
| 55 | + filter_by = |
| 56 | + cond do |
| 57 | + opts[:everywhere] -> |
| 58 | + nil |
| 59 | + |
| 60 | + opts[:package] not in [nil, []] -> |
| 61 | + filter_from_packages(opts[:package]) |
| 62 | + |
| 63 | + true -> |
| 64 | + filter_from_mix_lock() |
| 65 | + end |
| 66 | + |
| 67 | + query_params = |
| 68 | + %{ |
| 69 | + q: term, |
| 70 | + query_by: "doc,title", |
| 71 | + page: opts[:page] || 1, |
| 72 | + per_page: opts[:per_page] || 10 |
| 73 | + } |
| 74 | + |> maybe_add_filter(filter_by) |
| 75 | + |
| 76 | + case Req.get("https://search.hexdocs.pm/", params: query_params) do |
| 77 | + {:ok, %{status: 200, body: body}} -> |
| 78 | + output_format = opts[:output] || "markdown" |
| 79 | + |
| 80 | + case output_format do |
| 81 | + "json" -> |
| 82 | + Mix.shell().info(Jason.encode!(body, pretty: true)) |
| 83 | + |
| 84 | + "markdown" -> |
| 85 | + format_markdown_output(body, term, opts) |
| 86 | + |
| 87 | + _ -> |
| 88 | + Mix.raise("Invalid output format '#{output_format}'. Use 'json' or 'markdown'") |
| 89 | + end |
| 90 | + |
| 91 | + {:ok, %{status: status, body: body}} -> |
| 92 | + Mix.raise("HTTP error #{status} - #{inspect(body)}") |
| 93 | + |
| 94 | + {:error, reason} -> |
| 95 | + Mix.raise("Request failed\n\n#{inspect(reason)}") |
| 96 | + end |
| 97 | + end |
| 98 | + |
| 99 | + defp maybe_add_filter(query_params, nil), do: query_params |
| 100 | + defp maybe_add_filter(query_params, filter_by), do: Map.put(query_params, :filter_by, filter_by) |
| 101 | + |
| 102 | + defp format_markdown_output(body, term, opts) do |
| 103 | + %{ |
| 104 | + "found" => total_found, |
| 105 | + "hits" => hits, |
| 106 | + "page" => current_page, |
| 107 | + "request_params" => %{"per_page" => per_page} |
| 108 | + } = body |
| 109 | + |
| 110 | + current_page = current_page || 1 |
| 111 | + per_page = per_page || 10 |
| 112 | + total_pages = ceil(total_found / per_page) |
| 113 | + |
| 114 | + # Build the full markdown content |
| 115 | + markdown_content = |
| 116 | + build_markdown_content(term, opts, total_found, current_page, total_pages, hits, per_page) |
| 117 | + |
| 118 | + # Output using appropriate method |
| 119 | + if tty?() do |
| 120 | + IO.ANSI.Docs.print(markdown_content, "text/markdown", []) |
| 121 | + else |
| 122 | + Mix.shell().info(markdown_content) |
| 123 | + end |
| 124 | + end |
| 125 | + |
| 126 | + defp build_markdown_content(term, opts, total_found, current_page, total_pages, hits, per_page) do |
| 127 | + # Header with help text at the top |
| 128 | + search_scope = if opts[:everywhere], do: " across all packages", else: "" |
| 129 | + |
| 130 | + header = """ |
| 131 | + # Search Results for "#{term}"#{search_scope} |
| 132 | + |
| 133 | + Found #{total_found} results (page #{current_page} of #{total_pages}) |
| 134 | + |
| 135 | + #{format_navigation_help_markdown(term, current_page, total_pages, opts)} |
| 136 | + |
| 137 | + --- |
| 138 | + |
| 139 | + """ |
| 140 | + |
| 141 | + # Results - reverse order in TTY for better readability |
| 142 | + results = |
| 143 | + hits |
| 144 | + |> if(tty?(), do: &Enum.reverse/1, else: &Function.identity/1).() |
| 145 | + |> Enum.with_index(1) |
| 146 | + |> Enum.map_join("\n", fn {hit, index} -> |
| 147 | + # Adjust index calculation for reversed results |
| 148 | + actual_index = if tty?(), do: length(hits) - index + 1, else: index |
| 149 | + format_search_result_markdown(hit, actual_index, current_page, per_page) |
| 150 | + end) |
| 151 | + |
| 152 | + header <> results |
| 153 | + end |
| 154 | + |
| 155 | + defp format_search_result_markdown(hit, index, current_page, per_page) do |
| 156 | + %{ |
| 157 | + "document" => %{ |
| 158 | + "title" => title, |
| 159 | + "package" => package, |
| 160 | + "type" => type, |
| 161 | + "ref" => ref |
| 162 | + }, |
| 163 | + "highlights" => highlights |
| 164 | + } = hit |
| 165 | + |
| 166 | + # Calculate global result number |
| 167 | + global_index = (current_page - 1) * per_page + index |
| 168 | + |
| 169 | + # Convert <mark> tags to ANSI orange highlighting for TTY |
| 170 | + title_display = if tty?(), do: convert_mark_tags_to_ansi(title), else: title |
| 171 | + |
| 172 | + result = """ |
| 173 | + ## #{global_index}. #{title_display} |
| 174 | + |
| 175 | + **Package:** #{package} |
| 176 | + **Type:** #{type} |
| 177 | + **Reference:** #{ref} |
| 178 | + |
| 179 | + """ |
| 180 | + |
| 181 | + # Show highlighted snippets |
| 182 | + highlights_text = |
| 183 | + highlights |
| 184 | + # Limit to 2 highlights to avoid clutter |
| 185 | + |> Enum.take(2) |
| 186 | + |> Enum.map_join("\n\n", fn highlight -> |
| 187 | + snippet = highlight["snippet"] |
| 188 | + field = highlight["field"] |
| 189 | + |
| 190 | + # Convert <mark> tags to ANSI orange highlighting for TTY |
| 191 | + snippet_display = if tty?(), do: convert_mark_tags_to_ansi(snippet), else: snippet |
| 192 | + |
| 193 | + case field do |
| 194 | + "title" -> "**Title match:** #{snippet_display}" |
| 195 | + "doc" -> "**Content:** #{snippet_display}" |
| 196 | + _ -> "**#{String.capitalize(field)}:** #{snippet_display}" |
| 197 | + end |
| 198 | + end) |
| 199 | + |
| 200 | + footer = """ |
| 201 | + |
| 202 | + đź“– **View full documentation:** |
| 203 | + https://hexdocs.pm/#{extract_package_name(package)}/#{ref} |
| 204 | + |
| 205 | + --- |
| 206 | + |
| 207 | + """ |
| 208 | + |
| 209 | + result <> highlights_text <> footer |
| 210 | + end |
| 211 | + |
| 212 | + defp convert_mark_tags_to_ansi(text) do |
| 213 | + text |
| 214 | + # Orange color |
| 215 | + |> String.replace("<mark>", "\e[38;5;208m") |
| 216 | + # Reset color |
| 217 | + |> String.replace("</mark>", "\e[0m") |
| 218 | + end |
| 219 | + |
| 220 | + defp format_navigation_help_markdown(term, current_page, total_pages, opts) do |
| 221 | + base_cmd = build_base_command(term, opts) |
| 222 | + |
| 223 | + navigation = [] |
| 224 | + |
| 225 | + # Previous page |
| 226 | + navigation = |
| 227 | + if current_page > 1 do |
| 228 | + prev_cmd = "#{base_cmd} --page #{current_page - 1}" |
| 229 | + navigation ++ ["⬅️ **Previous page:** `#{prev_cmd}`"] |
| 230 | + else |
| 231 | + navigation |
| 232 | + end |
| 233 | + |
| 234 | + # Next page |
| 235 | + navigation = |
| 236 | + if current_page < total_pages do |
| 237 | + next_cmd = "#{base_cmd} --page #{current_page + 1}" |
| 238 | + navigation ++ ["- **Next page:** `#{next_cmd}`"] |
| 239 | + else |
| 240 | + navigation |
| 241 | + end |
| 242 | + |
| 243 | + # Jump to specific page |
| 244 | + navigation = |
| 245 | + if total_pages > 1 do |
| 246 | + jump_cmd = "#{base_cmd} --page <PAGE_NUMBER>" |
| 247 | + navigation ++ ["- **Jump to page:** `#{jump_cmd}`"] |
| 248 | + else |
| 249 | + navigation |
| 250 | + end |
| 251 | + |
| 252 | + if navigation != [] do |
| 253 | + "## Navigation\n\n" <> Enum.join(navigation, "\n") <> "\n\n" |
| 254 | + else |
| 255 | + "" |
| 256 | + end |
| 257 | + end |
| 258 | + |
| 259 | + defp build_base_command(term, opts) do |
| 260 | + base = "mix usage_rules.search_docs \"#{term}\"" |
| 261 | + |
| 262 | + # Add flags |
| 263 | + flags = [] |
| 264 | + |
| 265 | + # Add package filters |
| 266 | + packages = opts[:package] || [] |
| 267 | + package_args = Enum.map(packages, &"-p #{&1}") |
| 268 | + flags = flags ++ package_args |
| 269 | + |
| 270 | + # Add everywhere flag |
| 271 | + flags = |
| 272 | + if opts[:everywhere] do |
| 273 | + flags ++ ["--everywhere"] |
| 274 | + else |
| 275 | + flags |
| 276 | + end |
| 277 | + |
| 278 | + if flags != [] do |
| 279 | + "#{base} #{Enum.join(flags, " ")}" |
| 280 | + else |
| 281 | + base |
| 282 | + end |
| 283 | + end |
| 284 | + |
| 285 | + defp extract_package_name(package_with_version) do |
| 286 | + package_with_version |
| 287 | + |> String.split("-") |
| 288 | + |> Enum.drop(-1) |
| 289 | + |> Enum.join("-") |
| 290 | + end |
| 291 | + |
| 292 | + defp filter_from_mix_lock do |
| 293 | + apps = |
| 294 | + if apps_paths = Mix.Project.apps_paths() do |
| 295 | + Enum.filter(Mix.Project.deps_apps(), &is_map_key(apps_paths, &1)) |
| 296 | + else |
| 297 | + [Mix.Project.config()[:app]] |
| 298 | + end |
| 299 | + |
| 300 | + filter = |
| 301 | + apps |
| 302 | + |> Enum.flat_map(fn app -> |
| 303 | + Application.load(app) |
| 304 | + Application.spec(app, :applications) |
| 305 | + end) |
| 306 | + |> Enum.uniq() |
| 307 | + |> Enum.map_join(", ", fn app -> |
| 308 | + "#{app}-#{Application.spec(app, :vsn)}" |
| 309 | + end) |
| 310 | + |
| 311 | + "package:=[#{filter}]" |
| 312 | + end |
| 313 | + |
| 314 | + defp filter_from_packages(packages) do |
| 315 | + filter = |
| 316 | + packages |
| 317 | + |> Enum.flat_map(fn package -> |
| 318 | + case Req.get("https://hex.pm/api/packages/#{package}", []) do |
| 319 | + {:ok, %{status: 200, body: body}} -> |
| 320 | + ["#{package}-#{get_latest_version(body)}"] |
| 321 | + |
| 322 | + other -> |
| 323 | + Logger.warning( |
| 324 | + "Failed to get latest version for package #{package}: #{inspect(other)}" |
| 325 | + ) |
| 326 | + |
| 327 | + [] |
| 328 | + end |
| 329 | + end) |
| 330 | + |> Enum.join(", ") |
| 331 | + |
| 332 | + "package:=[#{filter}]" |
| 333 | + end |
| 334 | + |
| 335 | + defp get_latest_version(package) do |
| 336 | + versions = |
| 337 | + for release <- package["releases"], |
| 338 | + version = Version.parse!(release["version"]), |
| 339 | + # ignore pre-releases like release candidates, etc. |
| 340 | + version.pre == [] do |
| 341 | + version |
| 342 | + end |
| 343 | + |
| 344 | + Enum.max(versions, Version) |
| 345 | + end |
| 346 | + |
| 347 | + @doc false |
| 348 | + # assume we are not in a tty if we can't tell |
| 349 | + defp tty? do |
| 350 | + case :file.read_file_info("/dev/stdin") do |
| 351 | + {:ok, info} -> |
| 352 | + elem(info, 2) == :device |
| 353 | + |
| 354 | + _ -> |
| 355 | + false |
| 356 | + end |
| 357 | + rescue |
| 358 | + _ -> |
| 359 | + false |
| 360 | + end |
| 361 | + |
| 362 | + @spec raise_bad_args!() :: no_return() |
| 363 | + defp raise_bad_args! do |
| 364 | + Mix.raise(""" |
| 365 | + Must provide a search term. For example: |
| 366 | + $ mix hex.docs.search "search term" |
| 367 | + """) |
| 368 | + end |
| 369 | + end |
Loading more files…