lug

# Tools

The model works through six built-in tools. The descriptions below are sent to the model exactly as written (a test checks this).

Tool Asks by default Description
read No Read a UTF-8 text file. Returns its lines numbered from 1, like cat -n. For long files, pass offset (first line, 1-based) and limit (number of lines).
write Yes Create or overwrite a file with the given content, creating parent directories.
edit Yes Replace exact text in a file. old_string must match the file exactly, including whitespace, and only once unless replace_all is true.
grep No Search file contents with a regular expression (Rust regex syntax). Skips .git and files ignored by .gitignore. Returns matching lines as path:line: text.
ls No List a directory, marking directories with a trailing /. With glob, list every file under path that matches it instead, skipping .git and ignored files.
bash Yes Run a command with bash in the working directory. Returns the exit status and output (stdout, then stderr). Standard input is empty. To leave a process running, redirect its output: cmd > log 2>&1 &.
Tool Argument Type Required Default Description
all but bash path string for read, write, edit . Path, relative to the working directory or absolute.
read offset integer ≥ 1 1 First line to return, 1-based.
read limit integer ≥ 1 2000 How many lines to return.
write content string yes The complete new contents of the file.
edit old_string string yes The text to replace, copied exactly from the file.
edit new_string string yes The text to put in its place.
edit replace_all boolean false Replace every occurrence.
grep pattern string yes The regular expression.
grep, ls glob string Only files whose path matches, e.g. *.rs or src/**/*.ts.
grep ignore_case boolean false Match regardless of case.
bash command string yes The command line to run.
bash timeout_secs integer 1–600 120 Seconds before the command is killed.

The stock runtime, basic.lug, adds two that ask nothing, so the model can read the Lug it runs in before changing your config or a plugin: lua_docs searches the Lua reference (lug.docs: the annotations, the Lua API and the built-in modules), and lug_plugins lists your init.lua and each plugin's directory, whose source it then reads with read and grep.

Tools run unsandboxed for now, with your authority and lug's environment, in the project directory. Approvals are the guard: by default write, edit and bash ask before they run.

## Approvals

The approval option decides which calls ask first:

Value Behaviour
off Nothing asks. Every call runs, except those a rule denies.
policy (default) Rules, tools allowed always, and each tool's default above decide
all Every call asks, except those a rule denies and tools allowed always

The dialog shows what the call will do, with the diff for an edit or write computed from the file on disk. Its keys are in keys. Pressing A allows that tool for the rest of the session, including calls to it already waiting; :approvals lists those tools and :approvals clear revokes them. Decisions are logged to ~/.local/state/lug/approvals.log.

The calls the model makes in one turn run at once, up to tool_concurrency (10 by default; see configuration). Those that ask wait in the dialog one at a time, and each runs as soon as it is allowed. write and edit change one file at a time, so two edits to a file both land. The model gets the results in the order it made the calls.

### Auto mode

Auto mode is approval = "off": every call runs without asking, unless a rule denies it. Start in it with lug --auto (or lug.opt.approval = "off" in init.lua), and toggle it with :auto or <S-Tab>; turning it off restores the mode it replaced. The status line shows AUTO while it is on. Since tools are not sandboxed yet, the model can then change or delete anything you can, with nothing asking first.

Rules decide from Lua, for one tool or, with "*", for every tool:

lug.tool.rule("bash", function(args)
  if args.command:match("^git status") then return "allow" end
  return "ask"
end)
lug.tool.rule("write", "deny")

A rule returns "allow", "ask", "deny" or nothing, and perhaps a reason. Each call runs the rules for its tool and for "*" in the order they were made; the first "deny" denies it, even in auto mode. Otherwise any "ask" asks, then any rule that failed, and any "allow" allows. lug.tool.rule returns a function that removes the rule, and nil in place of a rule removes the tool's rules. A rule function runs as a coroutine and may wait, on lug.ui.confirm say: the call waits for its answer.

A plugin can answer in the dialog's place with lug.session.approve(fn), which gets the call and its preview and returns what the dialog's keys would (see the Lua API).

## Limits

Limit Value
Output, every tool 30,000 bytes; bash keeps the end, the others the start
read lines, when limit is not given 2,000
Longest line (read, grep) 2,000 characters
bash and lug.proc.run timeout 120 s; bash allows up to 600 s, and lug.proc.run takes a timeout

bash runs /bin/bash -c (or /bin/sh -c) with empty stdin, in its own process group, which is killed on timeout and on cancel. A background process whose output is redirected (cmd > log 2>&1 &) outlives the call and is killed when lug exits. lug.proc.run and lug.proc.spawn start programs the same way.

## Tools from Lua

lug.tool.register("today", {
  description = "Return today's date.",
  ask = false,
  handler = function()
    return lug.proc.run({ "date", "+%F" }).stdout
  end,
})

The handler runs inside lug with your authority, so text the model controls should reach commands only through lug.proc.run's argument list, never a shell string.