lib/planck/agent/external_tool.ex

defmodule Planck.Agent.ExternalTool do
  @moduledoc """
  Loads external tool definitions from `TOOL.json` files on the filesystem.

  Each external tool lives in its own subdirectory under a configured tools
  directory:

      <tools_dir>/
        check_complexity/
          TOOL.json
        run_linter/
          TOOL.json

  `TOOL.json` format:

      {
        "name":        "check_complexity",
        "description": "Check cyclomatic complexity of a file using radon.",
        "command":     "radon cc {{path}} -s",
        "parameters": {
          "type": "object",
          "properties": {
            "path": { "type": "string", "description": "File to analyse" }
          },
          "required": ["path"]
        }
      }

  `{{key}}` placeholders in `command` are replaced with the corresponding
  argument values at call time. Unknown placeholders are replaced with an empty
  string. `cwd` and `timeout` (milliseconds) can always be passed as runtime
  arguments in addition to the declared parameters.

  Commands run via `erlexec` — process groups are cleaned up on timeout or
  termination.

  ## Usage

      tools = Planck.Agent.ExternalTool.load_all(["~/.planck/tools"])

  Pass the returned `Tool.t()` structs alongside inter-agent tools when starting
  an agent or building the `grantable_tools` list for an orchestrator.
  """

  alias Planck.Agent.{BuiltinTools, Tool}

  @default_timeout 30_000

  @doc """
  Load all external tools from a list of directories.

  Each subdirectory containing a `TOOL.json` is loaded as a tool. Missing
  directories and malformed entries are silently skipped.
  """
  @spec load_all([Path.t()]) :: [Tool.t()]
  def load_all(dirs) do
    Enum.flat_map(dirs, &load_dir/1)
  end

  @doc """
  Load a single external tool from a `TOOL.json` file path.

  Returns `{:ok, tool}` or `{:error, reason}`.
  """
  @spec from_file(Path.t()) :: {:ok, Tool.t()} | {:error, String.t()}
  def from_file(path) do
    expanded = Path.expand(path)

    with {:ok, content} <- File.read(expanded),
         {:ok, data} <- Jason.decode(content),
         {:ok, tool} <- build_tool(data, path) do
      {:ok, tool}
    else
      {:error, reason} when is_binary(reason) ->
        {:error, reason}

      {:error, %Jason.DecodeError{} = e} ->
        {:error, "invalid JSON in #{path}: #{Exception.message(e)}"}

      {:error, posix} ->
        {:error, "cannot read #{path}: #{:file.format_error(posix)}"}
    end
  end

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  @spec load_dir(Path.t()) :: [Tool.t()]
  defp load_dir(dir) do
    expanded = Path.expand(dir)

    case File.ls(expanded) do
      {:error, _} -> []
      {:ok, entries} -> Enum.flat_map(entries, &load_entry(expanded, &1))
    end
  end

  @spec load_entry(Path.t(), String.t()) :: [Tool.t()]
  defp load_entry(dir, name) do
    path = Path.join([dir, name, "TOOL.json"])

    case from_file(path) do
      {:ok, tool} -> [tool]
      {:error, _} -> []
    end
  end

  @spec build_tool(map(), Path.t()) :: {:ok, Tool.t()} | {:error, String.t()}
  defp build_tool(spec, path)

  defp build_tool(
         %{
           "name" => name,
           "description" => desc,
           "command" => cmd,
           "parameters" => params
         },
         _path
       )
       when is_binary(name) and is_binary(desc) and is_binary(cmd) do
    template = cmd

    tool =
      Tool.new(
        name: name,
        description: desc,
        parameters: params,
        execute_fn: fn _agent_id, _id, args ->
          cwd = Map.get(args, "cwd", File.cwd!())
          timeout = Map.get(args, "timeout", @default_timeout)
          command = interpolate(template, args)
          BuiltinTools.run_bash(command, timeout, cwd)
        end
      )

    {:ok, tool}
  end

  defp build_tool(data, path) do
    required = ["name", "description", "command", "parameters"]
    missing = Enum.reject(required, &Map.has_key?(data, &1))
    {:error, "TOOL.json at #{path} missing required fields: #{Enum.join(missing, ", ")}"}
  end

  @spec interpolate(String.t(), map()) :: String.t()
  defp interpolate(template, args) do
    Regex.replace(~r/\{\{(\w+)\}\}/, template, fn _, key ->
      to_string(Map.get(args, key, ""))
    end)
  end
end