lib/planck/headless/config.ex

defmodule Planck.Headless.Config do
  @moduledoc """
  Resolved runtime configuration for `planck_headless`.

  Config is resolved by Skogsra, which reads from three sources in priority
  order (highest first):

  1. Environment variables (`PLANCK_*`)
  2. Application config — `config :planck, <key>, ...`
  3. Hardcoded defaults

  Values are cached in persistent terms via `preload/0` at application boot;
  the application also calls `validate!/0` to fail fast on malformed config.
  To change a value at runtime, set the application env and call the
  Skogsra-generated `reload_<key>/0` function.

  JSON config files (`~/.planck/config.json` and `.planck/config.json`) are
  read via `JsonBinding` as part of Skogsra's binding chain. Keys that appear
  in a JSON file override application config but are overridden by env vars.

  ## Env vars

  ### Planner config

  | Env var                   | Config key           | Default                           |
  |---------------------------|----------------------|-----------------------------------|
  | `PLANCK_DEFAULT_PROVIDER` | `:default_provider`  | `nil`                             |
  | `PLANCK_DEFAULT_MODEL`    | `:default_model`     | `nil`                             |
  | `PLANCK_SESSIONS_DIR`     | `:sessions_dir`      | `.planck/sessions`                |
  | `PLANCK_SKILLS_DIRS`      | `:skills_dirs`       | `.planck/skills:~/.planck/skills` |
  | `PLANCK_TEAMS_DIRS`       | `:teams_dirs`        | `.planck/teams:~/.planck/teams`   |
  | `PLANCK_SIDECAR`          | `:sidecar`           | `.planck/sidecar`                 |

  `*_DIRS` env vars take a colon-separated list; paths are expanded at runtime
  (`~` and relative paths resolved). The `:models` key has no env var
  equivalent — declare models in `.planck/config.json` or
  `config :planck, :models, [...]`.

  ### Provider API keys

  API keys are not included in `get/0` or the `%Config{}` struct to avoid
  accidental exposure in logs or inspect output. Use the generated getter
  functions directly (e.g. `Config.anthropic_api_key!/0`).

  | Env var               | Config key             | Used for                   |
  |-----------------------|------------------------|----------------------------|
  | `ANTHROPIC_API_KEY`   | `:anthropic_api_key`   | Anthropic (Claude) models  |
  | `OPENAI_API_KEY`      | `:openai_api_key`      | OpenAI models              |
  | `GOOGLE_API_KEY`      | `:google_api_key`      | Google (Gemini) models     |
  """

  use Skogsra

  defmodule Models do
    @moduledoc false

    # Skogsra type for the `models` config key. Accepts a list of model-entry
    # maps and parses them via `Planck.AI.Config.from_list/1`, producing a list
    # of `%Planck.AI.Model{}` structs. Invalid entries are skipped with a
    # warning (delegated to `Planck.AI.Config`). No env-var form — model
    # declarations are too structured for a flat string.

    use Skogsra.Type

    alias Planck.AI.Config, as: AIConfig

    @impl Skogsra.Type
    @spec cast(term()) :: {:ok, [Planck.AI.Model.t()]} | {:error, String.t()}
    def cast(list) when is_list(list), do: {:ok, AIConfig.from_list(list)}
    def cast(_), do: {:error, "expected a list of model maps"}
  end

  defmodule PathList do
    @moduledoc false

    use Skogsra.Type

    @impl Skogsra.Type
    @spec cast(term()) :: {:ok, [String.t()]} | {:error, String.t()}
    def cast(value) when is_binary(value) do
      paths =
        value
        |> String.split(":")
        |> Enum.map(&String.trim/1)
        |> Enum.reject(&(&1 == ""))

      {:ok, paths}
    end

    def cast(value) when is_list(value) do
      if Enum.all?(value, &is_binary/1) do
        {:ok, value}
      else
        {:error, "expected a list of strings, got: #{inspect(value)}"}
      end
    end

    def cast(value) do
      {:error, "expected a colon-separated string or list of strings, got: #{inspect(value)}"}
    end
  end

  @typedoc """
  The resolved configuration struct returned by `get/0`.
  """
  @type t :: %__MODULE__{
          default_provider: atom() | nil,
          default_model: String.t() | nil,
          sessions_dir: Path.t(),
          skills_dirs: [Path.t()],
          teams_dirs: [Path.t()],
          sidecar: Path.t(),
          models: [Planck.AI.Model.t()]
        }

  defstruct default_provider: nil,
            default_model: nil,
            sessions_dir: ".planck/sessions",
            skills_dirs: [".planck/skills", "~/.planck/skills"],
            teams_dirs: [".planck/teams", "~/.planck/teams"],
            sidecar: ".planck/sidecar",
            models: []

  @envdoc """
  Colon-separated list of JSON config files to read at boot, in order.
  Later files override earlier ones. Not read from the JSON files themselves —
  that would be circular. Defaults to the user-global file followed by the
  project-local file (project-local wins on collision).
  """
  app_env :config_files, :planck, :config_files,
    type: PathList,
    default: ["~/.planck/config.json", ".planck/config.json"]

  @envdoc """
  Ordered list of `.env` files to read for API keys.
  Global file is read first; project-local file wins on collision.
  Not read from the `.env` files themselves — that would be circular.
  """
  app_env :env_files, :planck, :env_files,
    type: PathList,
    default: ["~/.planck/.env", "./.planck/.env"]

  # Config keys that can also be set in .planck/config.json or ~/.planck/config.json.
  # API keys are intentionally excluded — credentials must not live in config files.
  @json [:system, Planck.Headless.Config.JsonBinding, :config]

  # API key binding order: system env → project .env → global .env → Elixir config.
  @dotenv [:system, Planck.Headless.Config.EnvBinding, :config]

  @envdoc "Default LLM provider (e.g. anthropic)."
  app_env :default_provider, :planck, :default_provider,
    type: :atom,
    default: nil,
    binding_order: @json

  @envdoc "Default model id within the default provider (e.g. claude-sonnet-4-6)."
  app_env :default_model, :planck, :default_model,
    default: nil,
    binding_order: @json

  @envdoc """
  UI locale (e.g. `"en"`, `"es"`). Set in `.planck/config.json` for a
  project-specific language or in `~/.planck/config.json` for a global
  preference. When absent the browser's Accept-Language header is used,
  falling back to English.
  """
  app_env :locale, :planck, :locale,
    default: nil,
    binding_order: @json

  @envdoc "Path to the sessions directory."
  app_env :sessions_dir, :planck, :sessions_dir,
    default: ".planck/sessions",
    binding_order: @json

  @envdoc "Colon-separated list of skill directories."
  app_env :skills_dirs, :planck, :skills_dirs,
    type: PathList,
    default: [".planck/skills", "~/.planck/skills"],
    binding_order: @json

  @envdoc "Colon-separated list of team directories."
  app_env :teams_dirs, :planck, :teams_dirs,
    type: PathList,
    default: [".planck/teams", "~/.planck/teams"],
    binding_order: @json

  @envdoc """
  Path to the sidecar Mix project directory. planck_headless starts the sidecar
  application from this path when it exists on disk. Set to a non-existent path
  to disable sidecar startup.
  """
  app_env :sidecar, :planck, :sidecar,
    os_env: "PLANCK_SIDECAR",
    default: ".planck/sidecar",
    binding_order: @json

  @envdoc """
  List of model declarations for local providers (and optional cloud model
  overrides). Each entry follows the `Planck.AI.Config` JSON format. Only
  readable from `.planck/config.json` or application config — no env var
  equivalent (the format is too structured for a flat string).

  Example (in .planck/config.json):
  ```json
  "models": [
    {
      "id":             "llama3.2",
      "provider":       "ollama",
      "base_url":       "http://localhost:11434",
      "context_window": 128000,
      "default_opts":   {"temperature": 0.7, "top_p": 0.9}
    },
    {
      "id":             "mistral",
      "provider":       "llama_cpp",
      "base_url":       "http://localhost:8080",
      "context_window": 32768,
      "default_opts":   {"temperature": 0.5}
    }
  ]
  ```
  """
  app_env :models, :planck, :models,
    type: Models,
    default: [],
    binding_order: @json

  # Provider API keys — not included in get/0 or %Config{} to avoid
  # accidental exposure. Use the generated getters directly.

  @envdoc "Anthropic API key."
  app_env :anthropic_api_key, :req_llm, :anthropic_api_key,
    os_env: "ANTHROPIC_API_KEY",
    default: nil,
    binding_order: @dotenv

  @envdoc "OpenAI API key."
  app_env :openai_api_key, :req_llm, :openai_api_key,
    os_env: "OPENAI_API_KEY",
    default: nil,
    binding_order: @dotenv

  @envdoc "Google API key."
  app_env :google_api_key, :req_llm, :google_api_key,
    os_env: "GOOGLE_API_KEY",
    default: nil,
    binding_order: @dotenv

  @doc "Return the fully-resolved config as a `%Planck.Headless.Config{}` struct."
  @spec get() :: t()
  def get do
    %__MODULE__{
      default_provider: default_provider!(),
      default_model: default_model!(),
      sessions_dir: sessions_dir!(),
      skills_dirs: skills_dirs!(),
      teams_dirs: teams_dirs!(),
      sidecar: sidecar!(),
      models: models!()
    }
  end
end