defmodule Mix.Tasks.Claude.Hooks.Run do @moduledoc """ Dispatcher for Claude hooks. This task reads the .claude.exs configuration and executes the appropriate hooks based on the event type and tool matcher. ## Usage mix claude.hooks.run EVENT_TYPE Where EVENT_TYPE is one of: - pre_tool_use - post_tool_use - stop - subagent_stop - user_prompt_submit - notification - pre_compact - session_start The task reads the event data from stdin and executes matching hooks sequentially. """ use Mix.Task @shortdoc "Executes Claude hooks for a given event type" @impl Mix.Task def run(args) do run(args, []) end @doc false def run(args, opts) when is_list(opts) do io_reader = Keyword.get(opts, :io_reader, &IO.read/2) config_reader = Keyword.get(opts, :config_reader, &Claude.Config.read/0) default_task_runner = fn "cmd", args, env_vars -> shell_command = Enum.join(args, " ") run_with_port(shell_command, env_vars) command, args, env_vars -> mix_command = "mix #{command} #{Enum.join(args, " ")}" run_with_port(mix_command, env_vars) end task_runner = Keyword.get(opts, :task_runner, default_task_runner) do_run(args, io_reader, config_reader, task_runner) end defp run_with_port(command, env_vars) do port_opts = [ :binary, :exit_status, :stderr_to_stdout, :use_stdio, :hide ] # Add env option if there are env vars to set port_opts = if map_size(env_vars) > 0 do # Convert map to list of {"KEY", "VALUE"} tuples for Port env_list = Enum.map(env_vars, fn {k, v} -> {to_string(k), to_string(v)} end) [{:env, env_list} | port_opts] else port_opts end port = Port.open({:spawn, command}, port_opts) handle_port_output(port) end defp handle_port_output(port, accumulated_output \\ "") do receive do {^port, {:data, data}} -> IO.write(:stderr, data) handle_port_output(port, accumulated_output <> data) {^port, {:exit_status, 0}} -> :ok {^port, {:exit_status, status}} -> exit({:shutdown, status}) end end defp do_run([event_type], io_reader, config_reader, task_runner) do event_json = io_reader.(:stdio, :eof) with {:ok, event_data} <- Jason.decode(event_json), {:ok, config} <- config_reader.() do execute_hooks(event_type, event_data, config, task_runner) else {:error, %Jason.DecodeError{}} -> IO.puts(:stderr, "Invalid JSON input") System.halt(1) {:error, reason} -> IO.puts(:stderr, "Failed to read .claude.exs: #{reason}") System.halt(1) end end defp do_run(_, _, _, _) do IO.puts(:stderr, "Usage: mix claude.hooks.run ") System.halt(1) end defp execute_hooks(event_type, event_data, config, task_runner) do event_atom = String.to_atom(event_type) hooks = get_hooks_for_event(config, event_atom) expanded_hooks = Claude.Hooks.Defaults.expand_hooks(hooks, event_atom) matching_hooks = filter_hooks_by_matcher(expanded_hooks, event_data) {results, _halted?} = execute_hooks_with_halt(matching_hooks, event_data, task_runner) failed_hooks = Enum.filter(results, fn {_hook, exit_code} -> exit_code != 0 end) if length(failed_hooks) > 0 do IO.puts(:stderr, "\n=== Hook Pipeline Failures ===") IO.puts( :stderr, "#{length(failed_hooks)} of #{length(matching_hooks)} hooks reported issues:" ) Enum.each(failed_hooks, fn {hook, exit_code} -> task_name = case hook do {task, _opts} -> task task when is_binary(task) -> task end IO.puts(:stderr, " • #{task_name} (exit code: #{exit_code})") end) IO.puts(:stderr, "\nReview the output above for details on each issue.") max_exit_code = failed_hooks |> Enum.map(fn {_hook, exit_code} -> exit_code end) |> Enum.max() System.halt(max_exit_code) end end defp get_hooks_for_event(config, event_type) do config |> Map.get(:hooks, %{}) |> Map.get(event_type, []) end defp filter_hooks_by_matcher(hooks, event_data) do tool_name = Map.get(event_data, "tool_name") source = Map.get(event_data, "source") event_type = Map.get(event_data, "hook_event_name") Enum.filter(hooks, fn hook -> result = case hook do {_task, opts} when is_list(opts) -> matcher = opts[:when] command_pattern = opts[:command] tool_matches = if event_type == "SessionStart" do matches_source?(matcher, source, event_data) else matches_tool?(matcher, tool_name, event_data) end if tool_matches && command_pattern do matches_command?(command_pattern, event_data) else tool_matches end task when is_binary(task) -> true end result end) end defp matches_source?(nil, _source, _event_data), do: true defp matches_source?("*", _source, _event_data), do: true defp matches_source?(matchers, source, _event_data) when is_list(matchers) do Enum.any?(matchers, fn matcher -> matches_single_source?(matcher, source) end) end defp matches_source?(matcher, source, _event_data) do matches_single_source?(matcher, source) end defp matches_single_source?(matcher, source) when is_atom(matcher) do Atom.to_string(matcher) == source end defp matches_single_source?(matcher, source) when is_binary(matcher) do matcher == source end defp matches_single_source?(_matcher, _source), do: false defp matches_tool?(nil, _tool_name, _event_data), do: true defp matches_tool?("*", _tool_name, _event_data), do: true defp matches_tool?(matchers, tool_name, event_data) when is_list(matchers) do Enum.any?(matchers, fn matcher -> matches_single_matcher?(matcher, tool_name, event_data) end) end defp matches_tool?(matcher, tool_name, event_data) do matches_single_matcher?(matcher, tool_name, event_data) end defp matches_single_matcher?(matcher, tool_name, _event_data) when is_atom(matcher) do formatted_tool = matcher |> Atom.to_string() |> Macro.camelize() formatted_tool == tool_name end defp matches_single_matcher?(matcher, tool_name, event_data) when is_binary(matcher) do cond do String.starts_with?(matcher, "Bash(") and String.ends_with?(matcher, ")") -> if tool_name == "Bash" do pattern = matcher |> String.trim_leading("Bash(") |> String.trim_trailing(")") |> String.trim_trailing(":*") command = get_in(event_data, ["tool_input", "command"]) || "" String.starts_with?(command, pattern) else false end true -> matcher == tool_name end end defp matches_single_matcher?(_matcher, _tool_name, _event_data), do: false defp matches_command?(pattern, event_data) when is_struct(pattern, Regex) do command = get_in(event_data, ["tool_input", "command"]) || "" Regex.match?(pattern, command) end defp matches_command?(pattern, event_data) when is_binary(pattern) do command = get_in(event_data, ["tool_input", "command"]) || "" String.starts_with?(command, pattern) end defp matches_command?(_pattern, _event_data), do: false defp execute_single_hook(hook, event_data, task_runner) do {task, opts} = case hook do {task, opts} when is_list(opts) -> {task, opts} {task} when is_binary(task) -> {task, []} tuple when is_tuple(tuple) and tuple_size(tuple) >= 2 -> [task | keyword_args] = Tuple.to_list(tuple) {task, keyword_args} task when is_binary(task) -> {task, []} end interpolated_task = interpolate_templates(task, event_data) {command, args} = if String.starts_with?(interpolated_task, "cmd ") do ["cmd" | rest_parts] = String.split(interpolated_task, " ") {"cmd", rest_parts} else [command | args] = String.split(interpolated_task, " ") {command, args} end env_vars = opts[:env] || %{} exit_code = try do task_runner.(command, args, env_vars) 0 rescue Mix.NoTaskError -> IO.puts(:stderr, """ The Mix task "#{command}" could not be found. If this is meant to be a shell command, prefix it with 'cmd': Example: "cmd #{command} #{Enum.join(args, " ")}" If this is meant to be a Mix task, ensure it exists by running: mix help """) 2 e in Mix.Error -> IO.puts(:stderr, e.message) 2 e -> IO.puts(:stderr, Exception.format(:error, e, __STACKTRACE__)) 2 catch :exit, {:shutdown, code} when is_integer(code) -> code :exit, reason -> IO.puts(:stderr, "Hook exited: #{inspect(reason)}") 2 end apply_exit_code_rules(exit_code, opts, event_data) end defp apply_exit_code_rules(exit_code, opts, _event_data) do blocking? = Keyword.get(opts, :blocking?, true) cond do exit_code == 0 -> 0 blocking? -> 2 true -> exit_code end end defp execute_hooks_with_halt(hooks, event_data, task_runner) do Enum.reduce_while(hooks, {[], false}, fn hook, {results, _halted?} -> exit_code = execute_single_hook(hook, event_data, task_runner) new_results = [{hook, exit_code} | results] should_halt = hook_should_halt?(hook) and exit_code != 0 if should_halt do {:halt, {Enum.reverse(new_results), true}} else {:cont, {new_results, false}} end end) |> case do {results, halted?} -> {Enum.reverse(results), halted?} end end defp hook_should_halt?(hook) do case hook do {_task, opts} when is_list(opts) -> Keyword.get(opts, :halt_pipeline?, false) _ -> false end end defp interpolate_templates(task, event_data) do Regex.replace(~r/\{\{([^}]+)\}\}/, task, fn _, path -> keys = String.split(path, ".") case get_in(event_data, keys) do nil -> "" value -> to_string(value) end end) end end