lug

# Configuration

Lug is configured in Lua 5.4. lug --init writes a commented starter file to ~/.config/lug/init.lua; it will not overwrite one that exists.

Lug uses the XDG directories on Linux and macOS alike: ~/.config/lug for your configuration, ~/.local/share/lug for plugins and ~/.local/state/lug for sessions, trust and approvals, or wherever XDG_CONFIG_HOME, XDG_DATA_HOME and XDG_STATE_HOME point. Lug 0.0.1 kept all three in ~/Library/Application Support/lug on macOS, and keeps using that directory while it holds an init.lua and ~/.config/lug does not. To move, put its init.lua, lug-lock.json and lua/ in ~/.config/lug; plugins clone again on the next start, and project trust is asked again.

Sessions are streams in ~/.local/state/lug/streams/session/ (see Streams). Lug 0.0.1 kept them in ~/.local/state/lug/sessions/; the first lug or lug ls to start converts those files, leaving any another lug has open for a later start.

## Load order

Lug's own defaults are Lua too: the stock runtime, basic.lug, a plugin like any other that your init.lua lists first. In order:

  1. ~/.config/lug/init.lua;
  2. the project's .lug/init.lua, once you trust it (below);
  3. each --cmd LUA given on the command line.

require("haul").setup loads the stock runtime where your file calls it, so a plain assignment after that call wins. An error in any of these is shown as a notice with the file and line, and loading goes on with the next file. lug --safe loads only the stock runtime, in place of all three. :reload runs the whole order again (not during a run).

require finds modules in ~/.config/lug/lua/, then in each plugin haul loaded, then in the few modules built into the binary (lug.bsp and lug.bootstrap), so your own modules shadow a plugin's, the stock runtime's among them.

## The stock runtime

basic.lug holds Lug's keymaps, commands, windows, statusline, themes and pickers. The starter init.lua (from lug --init or the installer) lists it, so the first start installs it. If Lug starts with no haul and nothing has opened a window, as with no init.lua at all, it offers to install it: y adds the lines below (under Plugins) to the top of your init.lua (writing one if there is none) and reloads, and n quits.

It is a separate repository with its own history. Like any plugin it follows its default branch unless you pin a tag or commit, and :haul update basic moves it on. To change one of its plugins, shadow the module (for example ~/.config/lug/lua/lug/layout.lua); to replace it, list another runtime instead.

## Project config and trust

A project can carry .lug/init.lua. Since the model's tools can write to the project, Lug loads that file only once you have trusted its exact contents: at startup it asks, showing the path and offering to view the file. Yes records the file's SHA-256 in ~/.local/state/lug/trust; no skips it for this session. If the file changes, Lug asks again.

## Plugins

Plugins are git repos that haul installs, pins in ~/.config/lug/lug-lock.json and loads in the order you list them. haul is a plugin too, so your init.lua clones it the first time, as the starter's does:

local haul = lug.dirs.data .. "/plugins/haul"
package.path = haul .. "/lua/?.lua;" .. haul .. "/lua/?/init.lua;" .. package.path
if not io.open(haul .. "/.git/HEAD") then
  for _, url in ipairs({ "git@github.com:klaatu01/haul.lug.git", "https://github.com/klaatu01/haul.lug" }) do
    local clone = { "env", "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10", "git", "clone", "--quiet", url, haul }
    if lug.proc.run(clone).code == 0 then break end
  end
end
require("haul").setup({
  "klaatu01/haul.lug", -- haul itself, so :haul update moves it on too
  "klaatu01/basic.lug",
  "someone/lug-git",
  { "someone/lug-tasks", tag = "v1.2.0", config = function(tasks) tasks.setup({}) end },
  { dir = "~/src/my-plugin.lug" }, -- a local folder, loaded as it is
})

A plugin lists the plugins it needs in a haul.lua at its root, return { dependencies = { "someone/lug-json" } }, and haul installs and loads those first. :haul opens the plugins panel and :haul update [NAME] moves them on. haul's README has the rest: specs, pinning, local plugins and the panel's keys.

Plugins run with your full authority and the whole Lua standard library, with no sandbox. Install only plugins you would trust with your shell. Lug's own features (the layout, statusline, keymaps, commands, themes, the session picker) are plugins of this kind in basic.lug, and they are the best examples of the API.

## Models and keys

model is provider/model. There is none by default: Lug starts, and sending says to set one. LUG_MODEL, the model option and --model set it, the later winning.

Provider Example Key variable Base URL variable, default
Anthropic anthropic/claude-opus-5 ANTHROPIC_API_KEY ANTHROPIC_BASE_URL, https://api.anthropic.com
OpenAI openai/<model> OPENAI_API_KEY OPENAI_BASE_URL, https://api.openai.com/v1
OpenRouter openrouter/<vendor>/<model> OPENROUTER_API_KEY https://openrouter.ai/api/v1
Ollama ollama/<model> OLLAMA_API_KEY (optional) OLLAMA_API_BASE_URL, http://localhost:11434
OpenCode Go opencode-go/kimi-k3 OPENCODE_API_KEY https://opencode.ai/zen/go/v1

A key is looked up in this order:

  1. the providers option, which can also set a base URL:

    lug.opt.providers = {
      ["opencode-go"] = { api_key = "sk-..." },
      ollama = { base_url = "http://gpu-box:11434" },
    }
    
  2. ~/.lug/auth.json, which :login PROVIDER KEY writes (owner-only);

  3. the environment variable in the table.

Keys are read when a run starts, so a change applies from the next message.

providers can also add a provider that speaks one of the APIs Lug has a client for, its wire: anthropic (the Messages API), openai (the Responses API), openai-chat (Chat Completions, which most OpenAI-compatible servers speak), openrouter or ollama. It needs a base_url and a wire; a key is optional, from api_key or :login.

lug.opt.providers = {
  ["local"] = { wire = "openai-chat", base_url = "http://localhost:8080/v1" },
}
lug.opt.model = "local/qwen3"

A provider that serves no /models endpoint, as a gateway often does not, has no listing for :model and its picker to offer. Name its models and they become its listing:

lug.opt.models = {
  ["local"] = { "qwen3", "qwen3-coder" },
}

These lead the listing wherever a provider does serve one, and what it says of them, such as a context window, is kept.

Lug asks every provider to cache the prompt, so a run pays for the transcript it has already sent at the cache's price rather than in full. On the Messages API it sends Anthropic's automatic cache_control, which moves the cache breakpoint along as the conversation grows; the OpenAI wires cache a repeated prefix on their own. cached_input_tokens in turn_finished and run_finished says how much of a turn was served from the cache.

## Options

Read and assign options through lug.opt. A bad value or an unknown name raises an error and the old value stands. :set lists them all, :set NAME shows one and :set NAME=VALUE sets one.

Option Type Default Meaning
model provider/model, or "" for none $LUG_MODEL, else "" The model (applies from the next run); with none, Lug starts and sending says to set one
model_roles table: role → provider/model {} What a plugin's "@role" model means, fast or strong say. A role not set is the model option.
providers table: provider → { api_key, auth, base_url, wire } {} Keys and base URLs, over the environment, and providers you add; auth = "bearer" also sends the key as Authorization: Bearer
models table: provider → list of model ids {} The models a provider serves. They lead its listing, so :model and the picker offer them; for a provider that serves no /models they are the whole of it.
thinking default, off, low, medium, high, xhigh, max default Thinking level
system_prompt string Lug's prompt, with the workspace filled in The system prompt. basic.lug adds the project's AGENTS.md and CLAUDE.md to it, and how @path references read.
max_turns integer, 1 or more 50 Model calls per run
tool_concurrency integer, 1 or more 10 How many of one model call's tool calls run at once. Their results go back in the order the model made them.
approval off, policy, all policy Which tool calls ask first (see tools)
lua_timeout integer, ms 1000 The longest Lua may run without yielding before it is stopped
leader key <Space> What <leader> means in mappings
timeout_len integer, ms 500 How long a key sequence waits for its next key
start_mode insert, normal insert The prompt's first mode
split right, left, below, above right Where lug.ui.open puts a window when the plugin does not say
clipboard boolean true Copy yanks to the system clipboard (OSC 52)
mouse boolean true Capture the mouse, so the wheel arrives as the keys <ScrollUp> and <ScrollDown>
background dark, light Detected at startup Which palette auto picks
editor a program and its arguments $VISUAL, else $EDITOR What gf, :edit and the approval dialog's e open

The stock runtime, basic.lug, declares ten more with lug.opt.declare. They exist once it has loaded, so set them after require("haul").setup:

Option Type Default Meaning
animate boolean true The slug crawls along the prompt's top edge while the session works; off, it sits still
auto_compact boolean false Compact before a run once past 85% of the context window
border rounded, square, double, thick or none rounded The box around the prompt, whose top edge also runs above each other window with its name; none draws neither
dim_inactive integer, a percentage 20 How far windows away from the focus fade towards the background; the transcript and the prompt count as one
launcher top or bottom top Where the slug style's launcher bar sits, the : line's and the pickers'
prompt_max_lines integer 12 Rows the prompt grows to before it scrolls
style slug or plain slug The feel: slug has the splash on a new session, the launcher for : and the pickers, the slug crawling while lug works, and the prompt boxed with the other windows named along a top edge; plain has none of them
theme auto or a palette name auto The colour palette
theme_overrides table shaped like a palette {} Colours laid over whichever palette is in use
tool_open list of tool names { "edit" } Tool cards that open when their call finishes

A plugin declares its own options the same way, and they work as Lug's do. Each accepted assignment fires the option_changed event.

## Themes

The palettes are slug, Lug's own black, white and green, github-dark, github-light and ansi. auto picks ansi when the terminal does not report truecolour, and otherwise slug or github-light to match background. :theme opens a picker that previews as you move; :theme NAME sets one.

theme_overrides replaces colours before the highlight groups are derived from them, so one change reaches every group built on it:

lug.opt.theme_overrides = { accent = "#ff8800" }

A theme sets the role groups, LugAccent, LugOk, LugWarn, LugError, LugMuted, LugTitle and LugSelection, and links the rest of its groups to them, as plugins link theirs (see lua-api), so changing a role changes everything built on it.

:hi Group shows a highlight group, and :hi Group fg=#ff8800 bold or :hi Group link=Other changes it until the next theme is applied. From Lua, use lug.hl.set and lug.hl.get.

## Examples

-- ~/.config/lug/init.lua
lug.opt.model = "anthropic/claude-opus-5"
lug.opt.thinking = "high"
lug.opt.leader = ","

lug.keymap.set("normal", "<leader>q", ":q")

lug.cmd.set("hello", function(cmd)
  lug.ui.notify("hello " .. (cmd.args[1] or "world"))
end, { desc = "say hello" })

lug.on("run_finished", function(event)
  if event.outcome.status == "failed" then
    lug.ui.notify("run failed: " .. event.outcome.error, "error")
  end
end)

Every lug.* function and event is in lua-api.