An embeddable LLM agent in C with a Lua plugin system.
Copyright (c) 2026 Nick Owens mischief@offblast.org
ISC License — see LICENSE
clm-config(5)'s kind field~/.config/clm/agents/, switchable with -a/--agent or the TUI's
/agent/provider, /model, /agent swap the live
connection or persona mid-conversation — no restart, history intact/clear resets to a fresh conversation on
demandrate_tokens_per_sec/rate_burst per
provider) pacing outgoing LLM requests against a backend's real quotaesp32/README.md for a from-scratch embedder exampleRequires: meson ≥ 1.1, a C17 compiler, libcurl, libuv, cJSON, ncursesw, md4c, and Lua 5.4.
OpenBSD:
pkg_add lua%5.4 md4c libuv cjson curl
Debian/Ubuntu:
apt install meson libcurl4-openssl-dev libuv1-dev libcjson-dev \
libncursesw5-dev libmd4c-dev liblua5.4-dev
meson setup build
meson compile -C build
meson test -C build
-Dlua=disabled builds libclm (the portable core) without Lua support at
all -- for embedding the core into something with no terminal and no Lua
(the ESP32 port, for instance). It also skips libclmlua and, with it, the
clm binary itself: the ncurses/CLI frontend always requires Lua (agent
profiles, plugins), so there's nothing for this flag to build there. Use it
when you only want libclm as a library, not when building clm the
program:
meson setup build -Dlua=disabled
# First run: writes a starter config.lua under $XDG_CONFIG_HOME/clm/ --
# nine ready-to-use provider connections (Anthropic, plus eight
# OpenAI-compatible ones with a free tier: Groq, Cerebras, NVIDIA,
# OpenRouter, GitHub Models, Ollama Cloud, LLM7, Google) -- and a
# matching secrets.lua with a blank slot for each key. Also seeds the
# builtin plugins. Safe to re-run -- never overwrites existing files.
clm setup
The only step left is a key. Open ~/.config/clm/secrets.lua and fill
in one entry -- each provider in config.lua has a comment right above
it linking to that service's free-key signup page, e.g.:
-- ~/.config/clm/secrets.lua
return {
groq = "gsk_...", -- from https://console.groq.com/keys
...
}
A provider with no key just sits inert (nil, not an error) until you
pick it via -m/--model or config.lua's top-level model field, so
there's no need to touch the ones you don't use. Then:
# Interactive TUI (default when on a terminal); "provider/model-id"
# picks the config.lua connection and model to use
clm -m groq/llama-3.3-70b-versatile
# Headless oneshot -- same spec form
clm -m anthropic/claude-sonnet-5 -o "what time is it?"
# No config.lua at all -- point straight at an endpoint (e.g. a local
# llama.cpp server). No wire dialect to resolve without a config, so
# this always speaks OpenAI-compatible (/chat/completions).
clm -u http://localhost:8080/v1
Free-tier keys are real but rate-limited for light use, not heavy
agentic sessions -- see the comments in the canned config.lua (and
clm-config(5)'s rate_tokens_per_sec /
rate_burst) before relying on one as a daily driver. Anthropic has no
free tier but is the most dependable option once you have a key.
See clm-config(5) for the full config.lua
schema (providers, per-model overrides, agent profiles, MCP servers,
per-plugin tool config) and clm(1) for the complete
CLI reference. Options:
| Flag | Description |
|---|---|
-m, --model PROVIDER/MODEL-ID | A config.lua providers[PROVIDER].models[MODEL-ID] entry, or a literal model id (no /) on whatever connection is otherwise active |
--provider NAME | Override which config.lua providers[] entry supplies the connection, without changing the requested model |
-u, --url BASE | Base API endpoint (default http://127.0.0.1:8081/v1); the request path appended depends on the wire dialect (/messages for Anthropic, /chat/completions otherwise) |
-a, --agent NAME | Load an agent profile from ~/.config/clm/agents/<name>.lua |
-o, --oneshot PROMPT | Run one prompt headless and exit |
-f, --forever PROMPT | TUI mode: submit PROMPT, then auto-resubmit it whenever a turn completes with nothing queued |
-p, --plugins DIR | Plugin directory (default $XDG_CONFIG_HOME/clm/plugins) |
-H, --headless | Force the plain stdio REPL |
-S, --no-stream | Disable streamed (SSE) responses |
-V, --version | Print version and exit |
With no options, clm runs the interactive ncurses UI on a terminal.
API keys are usually set once in secrets.lua and referenced from
config.lua as clm.secrets.<name> (see Secrets below,
and clm setup's canned files) — this is the normal path. The
CLM_API_KEY environment variable, when set, overrides whatever
config.lua would otherwise use for the active connection.
Plugins are Lua scripts in ~/.config/clm/plugins/. Each runs in a
sandboxed VM (no os, io, require) with an 8 MiB memory cap and a
CPU timeout.
-- ~/.config/clm/plugins/hello.lua
clm.tool_register("hello", {
description = "Say hello to someone",
params_schema = {
type = "object",
properties = {
name = { type = "string", description = "who to greet" },
},
required = { "name" },
},
invoke = function(args, ctx)
ctx:complete("Hello, " .. args.name .. "!")
end,
})
Plugins can make async HTTP requests (the coroutine yields and resumes on completion):
local resp, err = http.get("https://example.com/api")
local resp, err = http.post(url, body, {["Authorization"] = "Bearer ..."})
Per-plugin configuration lives in ~/.config/clm/config.lua:
return {
tools = {
web_search = { api_key = "tvly-..." },
weather = { units = "metric" },
},
}
Each plugin receives only its own section as clm.config.
clm can also pull in tools from external MCP
servers, configured in the same ~/.config/clm/config.lua:
return {
mcp_servers = {
-- Spawned as a subprocess; speaks JSON-RPC over its stdin/stdout.
{ name = "fs", transport = "stdio", command = {"mcp-server-fs", "/home/me/notes"} },
-- A remote server; one JSON-RPC POST per call, no persistent connection.
{ name = "search", transport = "http", url = "https://example.com/mcp", api_key = "..." },
},
}
transport defaults to "stdio" if omitted. timeout_ms is optional on
either kind (per-call deadline; defaults to 30s). Each remote tool is
registered as mcp__<server name>__<tool name> (matching the scheme Claude
Code uses for MCP-sourced tools), so identically-named tools from different
servers -- or from a built-in like read_file -- never collide.
A stdio server that crashes is automatically restarted (with a small backoff budget, so a genuine crash loop doesn't turn into a fork/exec storm); its tools disappear from the model's view while it's down and reappear once it's back. The HTTP transport is newer and less exercised: it expects a plain JSON response per call, not the SSE-streamed variant some MCP servers use.
API keys and other secrets live in a separate ~/.config/clm/secrets.lua
(mode 600), not in config.lua itself, so config.lua can be shared
or checked into dotfiles without leaking anything:
-- ~/.config/clm/secrets.lua
return {
tavily = "tvly-...",
}
clm.secrets itself (the live lookup table) is only ever visible where
it's resolved: config.lua and per-agent profile files under
~/.config/clm/agents/, which share one Lua state for exactly this
reason:
return {
tools = {
web_search = { api_key = clm.secrets.tavily },
},
}
Each plugin runs in its own separate, sandboxed Lua state (see
Plugins above) with no access to clm.secrets or to any
other plugin's config — but the value a secret resolved to still
reaches the plugin it's configured for, as plain data: config.lua is
evaluated once (substituting clm.secrets.tavily for its real string),
and each plugin's own slice of the resulting tools table is handed to
it as clm.config. So web_search's invoke function can read
clm.config.api_key and get the real key, without the plugin sandbox
ever holding a reference to clm.secrets or seeing any other tool's
configuration.
clm warns (via CLM_DEBUG_LOG) if secrets.lua is readable by group
or other. clm setup writes a starter secrets.lua with the right
permissions.
Tools whose output is a state snapshot (a map read, a status query) leave
stale copies in the conversation as they are re-called; the history grows
without bound and the model can act on out-of-date data. Declaring them
volatile keeps only the newest result: when such a tool completes, every
prior result from it is replaced in place with a short
[superseded by newer <tool>] stub. Stubbed entries never change again,
so the request prefix stays byte-stable for backend prompt caching, and
call/result pairing is preserved (results are stubbed, never removed).
volatile_tools is a list of fnmatch(3) patterns, set in config.lua
or per agent profile:
return {
volatile_tools = { "local_map", "character_status" },
}
esp32/README.md-Dstatic=true links libclm and every third-party dependency (cJSON,
lua5.4, libcurl, libuv, ncursesw, md4c, and curl's own chain) as static
archives, in one flag:
meson setup build-static -Dstatic=true -Dtests=false
meson compile -C build-static
On glibc this still leaves libc itself dynamic (ldd shows only
libc/libm/the loader) — glibc's static linking has NSS/dlopen
caveats that don't matter for most builds but are worth knowing about.
Every third-party lib needs a static archive on disk (.a, not just
.so) for this to work; on distros that don't ship one by default
(e.g. Gentoo's static-libs USE flag, or a -static / -dev package
elsewhere), you'll need to install or rebuild it first.
For a truly portable binary with zero dynamic dependencies — including libc — build against musl instead, with a cross-file:
meson setup build-musl --cross-file cross/x86_64-linux-musl \
-Dstatic=true -Dtests=false
meson compile -C build-musl
This needs a musl cross-toolchain (e.g. via Gentoo's crossdev --target x86_64-linux-musl) with the same dependency stack built
static into its sysroot. cross/x86_64-linux-musl assumes the sysroot
lives at /usr/x86_64-linux-musl; adjust sys_root and the x86_64- linux-musl-* tool names in that file for a different toolchain layout.
musl has no <sys/queue.h>; compat/sys/queue.h (vendored from glibc,
BSD-3-Clause) is picked up automatically as a fallback via -idirafter
only when the system doesn't provide one.
cross/aarch64-linux-musl-zig builds a static-PIE aarch64 binary using
zig cc as the cross compiler. zig ships musl's sources and headers, so
this needs no aarch64 sysroot and no crossdev target — only zig on
PATH. Every third-party dependency comes from the wraps in
subprojects/ — except ncursesw, which needs one build of its own:
cross/build-ncurses.sh ncurses-6.5.tar.gz # once, into the sysroot
meson setup build-a64 --cross-file cross/aarch64-linux-musl-zig \
-Dstatic=true -Dtests=false --default-library=static --prefer-static \
--force-fallback-for=curl,libuv,cjson,md4c,openssl
meson compile -C build-a64 src/clm
Two things the cross file works around:
cross/build-ncurses.sh builds it with the
same zig cc target and installs the static library and its .pc
file under $HOME/aarch64-musl-sysroot, which is what the cross
file's sys_root and pkg_config_libdir point at. Two of its
configure flags matter: --with-pic, because the static PIE link
rejects ncurses' non-PIC relocations, and --with-fallbacks, which
compiles a handful of terminal descriptions into the binary. Embedded
targets often ship no terminfo database at all, and without a
fallback entry the TUI dies at startup with Error opening terminal
whatever TERM says.cross/zig-ar wraps zig ar and drops
the T flag.Test the result under qemu-aarch64. Build only the src/clm target:
cJSON's own test executables fail the same thin-archive link, and
nothing needs them.
Linux — ASan + UBSan:
meson setup build-asan \
-Db_sanitize=address,undefined \
-Db_lundef=false \
-Ddefault_library=static \
-Dc_link_args='-static-libasan'
meson test -C build-asan
OpenBSD — trap-based UBSan:
meson setup build-ubsan \
-Dc_args="-fsanitize=undefined -fsanitize-trap=undefined" \
-Dc_link_args="-Wl,--no-execute-only"
meson test -C build-ubsan
Fuzz targets are built alongside the tests but never run by meson test.
They use AFL++ persistent mode, so the build needs afl-clang-fast:
CC=afl-clang-fast meson setup build-fuzz -Dtests=true
ninja -C build-fuzz fuzz/fuzz_session
afl-fuzz -i fuzz/corpus/session -o fuzz/out_session -- ./build-fuzz/fuzz/fuzz_session
Add AFL_USE_ASAN=1 at setup time to fuzz under AddressSanitizer. Built
with any other compiler, a target reads one case from stdin instead, which
is how you replay a crash:
./build/fuzz/fuzz_session < fuzz/out_session/default/crashes/id:000000*
Targets: fuzz_tool_dispatch (and fuzz_tool_dispatch_lua) for adversarial
tool-call JSON, fuzz_session for the session log loader, fuzz_text_clean
for captured tool output, fuzz_md for the markdown renderer.
ninja -C build clang-format # format source
ninja -C build clang-tidy # static analysis
ninja -C build cppcheck # additional checks
meson test -C build --suite docs # man page lint
Hacker News (1)
C
84.6%
Lua
7.1%
Meson
7.0%
An embeddable LLM agent in C with a Lua plugin system.
Copyright (c) 2026 Nick Owens mischief@offblast.org
ISC License — see LICENSE
clm-config(5)'s kind field~/.config/clm/agents/, switchable with -a/--agent or the TUI's
/agent/provider, /model, /agent swap the live
connection or persona mid-conversation — no restart, history intact/clear resets to a fresh conversation on
demandrate_tokens_per_sec/rate_burst per
provider) pacing outgoing LLM requests against a backend's real quotaesp32/README.md for a from-scratch embedder exampleRequires: meson ≥ 1.1, a C17 compiler, libcurl, libuv, cJSON, ncursesw, md4c, and Lua 5.4.
OpenBSD:
pkg_add lua%5.4 md4c libuv cjson curl
Debian/Ubuntu:
apt install meson libcurl4-openssl-dev libuv1-dev libcjson-dev \
libncursesw5-dev libmd4c-dev liblua5.4-dev
meson setup build
meson compile -C build
meson test -C build
-Dlua=disabled builds libclm (the portable core) without Lua support at
all -- for embedding the core into something with no terminal and no Lua
(the ESP32 port, for instance). It also skips libclmlua and, with it, the
clm binary itself: the ncurses/CLI frontend always requires Lua (agent
profiles, plugins), so there's nothing for this flag to build there. Use it
when you only want libclm as a library, not when building clm the
program:
meson setup build -Dlua=disabled
# First run: writes a starter config.lua under $XDG_CONFIG_HOME/clm/ --
# nine ready-to-use provider connections (Anthropic, plus eight
# OpenAI-compatible ones with a free tier: Groq, Cerebras, NVIDIA,
# OpenRouter, GitHub Models, Ollama Cloud, LLM7, Google) -- and a
# matching secrets.lua with a blank slot for each key. Also seeds the
# builtin plugins. Safe to re-run -- never overwrites existing files.
clm setup
The only step left is a key. Open ~/.config/clm/secrets.lua and fill
in one entry -- each provider in config.lua has a comment right above
it linking to that service's free-key signup page, e.g.:
-- ~/.config/clm/secrets.lua
return {
groq = "gsk_...", -- from https://console.groq.com/keys
...
}
A provider with no key just sits inert (nil, not an error) until you
pick it via -m/--model or config.lua's top-level model field, so
there's no need to touch the ones you don't use. Then:
# Interactive TUI (default when on a terminal); "provider/model-id"
# picks the config.lua connection and model to use
clm -m groq/llama-3.3-70b-versatile
# Headless oneshot -- same spec form
clm -m anthropic/claude-sonnet-5 -o "what time is it?"
# No config.lua at all -- point straight at an endpoint (e.g. a local
# llama.cpp server). No wire dialect to resolve without a config, so
# this always speaks OpenAI-compatible (/chat/completions).
clm -u http://localhost:8080/v1
Free-tier keys are real but rate-limited for light use, not heavy
agentic sessions -- see the comments in the canned config.lua (and
clm-config(5)'s rate_tokens_per_sec /
rate_burst) before relying on one as a daily driver. Anthropic has no
free tier but is the most dependable option once you have a key.
See clm-config(5) for the full config.lua
schema (providers, per-model overrides, agent profiles, MCP servers,
per-plugin tool config) and clm(1) for the complete
CLI reference. Options:
| Flag | Description |
|---|---|
-m, --model PROVIDER/MODEL-ID | A config.lua providers[PROVIDER].models[MODEL-ID] entry, or a literal model id (no /) on whatever connection is otherwise active |
--provider NAME | Override which config.lua providers[] entry supplies the connection, without changing the requested model |
-u, --url BASE | Base API endpoint (default http://127.0.0.1:8081/v1); the request path appended depends on the wire dialect (/messages for Anthropic, /chat/completions otherwise) |
-a, --agent NAME | Load an agent profile from ~/.config/clm/agents/<name>.lua |
-o, --oneshot PROMPT | Run one prompt headless and exit |
-f, --forever PROMPT | TUI mode: submit PROMPT, then auto-resubmit it whenever a turn completes with nothing queued |
-p, --plugins DIR | Plugin directory (default $XDG_CONFIG_HOME/clm/plugins) |
-H, --headless | Force the plain stdio REPL |
-S, --no-stream | Disable streamed (SSE) responses |
-V, --version | Print version and exit |
With no options, clm runs the interactive ncurses UI on a terminal.
API keys are usually set once in secrets.lua and referenced from
config.lua as clm.secrets.<name> (see Secrets below,
and clm setup's canned files) — this is the normal path. The
CLM_API_KEY environment variable, when set, overrides whatever
config.lua would otherwise use for the active connection.
Plugins are Lua scripts in ~/.config/clm/plugins/. Each runs in a
sandboxed VM (no os, io, require) with an 8 MiB memory cap and a
CPU timeout.
-- ~/.config/clm/plugins/hello.lua
clm.tool_register("hello", {
description = "Say hello to someone",
params_schema = {
type = "object",
properties = {
name = { type = "string", description = "who to greet" },
},
required = { "name" },
},
invoke = function(args, ctx)
ctx:complete("Hello, " .. args.name .. "!")
end,
})
Plugins can make async HTTP requests (the coroutine yields and resumes on completion):
local resp, err = http.get("https://example.com/api")
local resp, err = http.post(url, body, {["Authorization"] = "Bearer ..."})
Per-plugin configuration lives in ~/.config/clm/config.lua:
return {
tools = {
web_search = { api_key = "tvly-..." },
weather = { units = "metric" },
},
}
Each plugin receives only its own section as clm.config.
clm can also pull in tools from external MCP
servers, configured in the same ~/.config/clm/config.lua:
return {
mcp_servers = {
-- Spawned as a subprocess; speaks JSON-RPC over its stdin/stdout.
{ name = "fs", transport = "stdio", command = {"mcp-server-fs", "/home/me/notes"} },
-- A remote server; one JSON-RPC POST per call, no persistent connection.
{ name = "search", transport = "http", url = "https://example.com/mcp", api_key = "..." },
},
}
transport defaults to "stdio" if omitted. timeout_ms is optional on
either kind (per-call deadline; defaults to 30s). Each remote tool is
registered as mcp__<server name>__<tool name> (matching the scheme Claude
Code uses for MCP-sourced tools), so identically-named tools from different
servers -- or from a built-in like read_file -- never collide.
A stdio server that crashes is automatically restarted (with a small backoff budget, so a genuine crash loop doesn't turn into a fork/exec storm); its tools disappear from the model's view while it's down and reappear once it's back. The HTTP transport is newer and less exercised: it expects a plain JSON response per call, not the SSE-streamed variant some MCP servers use.
API keys and other secrets live in a separate ~/.config/clm/secrets.lua
(mode 600), not in config.lua itself, so config.lua can be shared
or checked into dotfiles without leaking anything:
-- ~/.config/clm/secrets.lua
return {
tavily = "tvly-...",
}
clm.secrets itself (the live lookup table) is only ever visible where
it's resolved: config.lua and per-agent profile files under
~/.config/clm/agents/, which share one Lua state for exactly this
reason:
return {
tools = {
web_search = { api_key = clm.secrets.tavily },
},
}
Each plugin runs in its own separate, sandboxed Lua state (see
Plugins above) with no access to clm.secrets or to any
other plugin's config — but the value a secret resolved to still
reaches the plugin it's configured for, as plain data: config.lua is
evaluated once (substituting clm.secrets.tavily for its real string),
and each plugin's own slice of the resulting tools table is handed to
it as clm.config. So web_search's invoke function can read
clm.config.api_key and get the real key, without the plugin sandbox
ever holding a reference to clm.secrets or seeing any other tool's
configuration.
clm warns (via CLM_DEBUG_LOG) if secrets.lua is readable by group
or other. clm setup writes a starter secrets.lua with the right
permissions.
Tools whose output is a state snapshot (a map read, a status query) leave
stale copies in the conversation as they are re-called; the history grows
without bound and the model can act on out-of-date data. Declaring them
volatile keeps only the newest result: when such a tool completes, every
prior result from it is replaced in place with a short
[superseded by newer <tool>] stub. Stubbed entries never change again,
so the request prefix stays byte-stable for backend prompt caching, and
call/result pairing is preserved (results are stubbed, never removed).
volatile_tools is a list of fnmatch(3) patterns, set in config.lua
or per agent profile:
return {
volatile_tools = { "local_map", "character_status" },
}
esp32/README.md-Dstatic=true links libclm and every third-party dependency (cJSON,
lua5.4, libcurl, libuv, ncursesw, md4c, and curl's own chain) as static
archives, in one flag:
meson setup build-static -Dstatic=true -Dtests=false
meson compile -C build-static
On glibc this still leaves libc itself dynamic (ldd shows only
libc/libm/the loader) — glibc's static linking has NSS/dlopen
caveats that don't matter for most builds but are worth knowing about.
Every third-party lib needs a static archive on disk (.a, not just
.so) for this to work; on distros that don't ship one by default
(e.g. Gentoo's static-libs USE flag, or a -static / -dev package
elsewhere), you'll need to install or rebuild it first.
For a truly portable binary with zero dynamic dependencies — including libc — build against musl instead, with a cross-file:
meson setup build-musl --cross-file cross/x86_64-linux-musl \
-Dstatic=true -Dtests=false
meson compile -C build-musl
This needs a musl cross-toolchain (e.g. via Gentoo's crossdev --target x86_64-linux-musl) with the same dependency stack built
static into its sysroot. cross/x86_64-linux-musl assumes the sysroot
lives at /usr/x86_64-linux-musl; adjust sys_root and the x86_64- linux-musl-* tool names in that file for a different toolchain layout.
musl has no <sys/queue.h>; compat/sys/queue.h (vendored from glibc,
BSD-3-Clause) is picked up automatically as a fallback via -idirafter
only when the system doesn't provide one.
cross/aarch64-linux-musl-zig builds a static-PIE aarch64 binary using
zig cc as the cross compiler. zig ships musl's sources and headers, so
this needs no aarch64 sysroot and no crossdev target — only zig on
PATH. Every third-party dependency comes from the wraps in
subprojects/ — except ncursesw, which needs one build of its own:
cross/build-ncurses.sh ncurses-6.5.tar.gz # once, into the sysroot
meson setup build-a64 --cross-file cross/aarch64-linux-musl-zig \
-Dstatic=true -Dtests=false --default-library=static --prefer-static \
--force-fallback-for=curl,libuv,cjson,md4c,openssl
meson compile -C build-a64 src/clm
Two things the cross file works around:
cross/build-ncurses.sh builds it with the
same zig cc target and installs the static library and its .pc
file under $HOME/aarch64-musl-sysroot, which is what the cross
file's sys_root and pkg_config_libdir point at. Two of its
configure flags matter: --with-pic, because the static PIE link
rejects ncurses' non-PIC relocations, and --with-fallbacks, which
compiles a handful of terminal descriptions into the binary. Embedded
targets often ship no terminfo database at all, and without a
fallback entry the TUI dies at startup with Error opening terminal
whatever TERM says.cross/zig-ar wraps zig ar and drops
the T flag.Test the result under qemu-aarch64. Build only the src/clm target:
cJSON's own test executables fail the same thin-archive link, and
nothing needs them.
Linux — ASan + UBSan:
meson setup build-asan \
-Db_sanitize=address,undefined \
-Db_lundef=false \
-Ddefault_library=static \
-Dc_link_args='-static-libasan'
meson test -C build-asan
OpenBSD — trap-based UBSan:
meson setup build-ubsan \
-Dc_args="-fsanitize=undefined -fsanitize-trap=undefined" \
-Dc_link_args="-Wl,--no-execute-only"
meson test -C build-ubsan
Fuzz targets are built alongside the tests but never run by meson test.
They use AFL++ persistent mode, so the build needs afl-clang-fast:
CC=afl-clang-fast meson setup build-fuzz -Dtests=true
ninja -C build-fuzz fuzz/fuzz_session
afl-fuzz -i fuzz/corpus/session -o fuzz/out_session -- ./build-fuzz/fuzz/fuzz_session
Add AFL_USE_ASAN=1 at setup time to fuzz under AddressSanitizer. Built
with any other compiler, a target reads one case from stdin instead, which
is how you replay a crash:
./build/fuzz/fuzz_session < fuzz/out_session/default/crashes/id:000000*
Targets: fuzz_tool_dispatch (and fuzz_tool_dispatch_lua) for adversarial
tool-call JSON, fuzz_session for the session log loader, fuzz_text_clean
for captured tool output, fuzz_md for the markdown renderer.
ninja -C build clang-format # format source
ninja -C build clang-tidy # static analysis
ninja -C build cppcheck # additional checks
meson test -C build --suite docs # man page lint
Hacker News (1)
C
84.6%
Lua
7.1%
Meson
7.0%