Current section

42 Versions

Jump to

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…