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, , ...` 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_/0` function. JSON config files (`~/.planck/config.json` and `.planck/config.json`) are read via `JsonBinding` (internal module) 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. `Planck.Headless.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 "Skogsra type for the list of local model configurations." # 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 """ Skogsra type for path lists set via environment variables. Uses `:` as separator on Unix and `;` on Windows, matching each platform's `PATH` convention. Both separators are accepted on all platforms so that cross-platform config files stay portable. Drive-letter colons (e.g. `C:`) are never mistaken for separators because they are immediately followed by `\\` or `/`. Examples: Unix: `~/.planck/skills:.planck/skills` Windows: `~/.planck/skills;.planck/skills` """ 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(~r/;|:(?![\/\\])/) |> 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 path list 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