A terminal AI coding agent written in MoonBit, styled after Claude Code.
Streams from the Anthropic Messages API or any OpenAI-compatible endpoint, runs
six tools against your working directory (read, write, edit, bash,
grep, ls), and renders the whole thing append-only to your scrollback so
native selection and scrollback keep working.
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
source ~/.bashrc
moon build --target native --release
./_build/native/release/build/cmd/main/main.exeThe result is a single ELF that links only libc; TLS is statically vendored.
export ANTHROPIC_API_KEY=sk-ant-...
moon-code/help lists the slash commands, /clear resets the conversation, /status
shows the resolved setup, /exit quits. Esc interrupts a streaming response.
Mutating tools (write, edit, bash) ask before they run, showing the diff
for a proposed file change; option 3 lets you deny with a note the model sees.
A prompt given as an argument runs one turn and exits, which is what makes the binary scriptable:
moon-code 'read moon.mod and tell me the module name'
moon-code --model claude-opus-4-1 'summarise the diff'When stdin is not a terminal there is nobody to prompt, so file edits run
unasked and shell commands are refused. Set
MOON_CODE_ALLOW_BASH_UNATTENDED=1 to allow them.
That default exists because the file tools (read, write, edit, grep,
ls) are confined to the directory moon-code was started in, and bash is
not. Asked for a path outside the project, a model gets the refusal and then
simply reaches for the shell; interactively you see that command in the prompt
and can deny it, but a scripted run would hand it over silently.
Paths resolve through realpath first, so a symlink inside the project
pointing outside it is refused rather than followed. Be clear on what this
buys: it stops a prompt injection from quietly reading or writing outside your
project, and it is not a sandbox. Anything with shell access has the machine,
so run untrusted work in a container.
Each session is appended to
~/.moon-code/sessions/<epoch-ms>-<id>.jsonl, one JSON object per turn; if that
write fails, moon-code warns once and carries on.
There is one resolution order, and the flags are shorthand for the same environment variables:
| Variable | Flag | Effect |
|---|---|---|
ANTHROPIC_API_KEY |
Selects Anthropic when set | |
OPENAI_API_KEY |
Selects OpenAI when ANTHROPIC_API_KEY is not set |
|
MOON_CODE_PROVIDER |
--provider |
Forces anthropic or openai; any other value is an error |
MOON_CODE_BASE_URL |
Endpoint override, trailing slash trimmed | |
MOON_CODE_MODEL |
--model |
Model override |
MOON_CODE_NO_CACHE |
Set to 1 to stop sending Anthropic prompt-caching markers |
|
MOON_CODE_ALLOW_BASH_UNATTENDED |
Set to 1 to allow shell commands when stdin is not a terminal |
Defaults are https://api.anthropic.com/v1 with claude-sonnet-5 for
Anthropic, and https://api.openai.com/v1 with gpt-4o for OpenAI. With
neither key set, moon-code prints what to export and exits non-zero.
MOON_CODE_BASE_URL plus OPENAI_API_KEY is what makes OpenRouter, Groq,
Together, Fireworks, vLLM, llama.cpp and LM Studio work:
export OPENAI_API_KEY=sk-or-...
export MOON_CODE_BASE_URL=https://openrouter.ai/api/v1
export MOON_CODE_MODEL=anthropic/claude-sonnet-4.5
moon-codeOn the Anthropic path the request carries cache_control breakpoints on the
last tool definition, on the system prompt, and on the last block of the most
recent user message. The tools and the system prompt are byte-identical every
turn, so after the first request they are read from the cache rather than
re-charged at full input price; the conversation breakpoint extends that to the
accumulated history. /status reports how many input tokens came back from the
cache this session.
Nothing changes on the OpenAI path: cache_control is Anthropic-specific and
several OpenAI-compatible endpoints reject unknown fields, so that request
builder never emits it. For an Anthropic-shaped gateway that proxies
/v1/messages without supporting caching, MOON_CODE_NO_CACHE=1 drops every
breakpoint and restores the plain-string system field.
Caching only engages above a model-dependent minimum prefix (1024 tokens on Sonnet, 4096 on Haiku 4.5), so short sessions on a small model may report no cache reads even though the markers were sent.
Early. It works and it is used daily, but every dependency is pre-1.0 and
moonbitlang/async says outright that its API will change, so expect breakage
on a toolchain bump. CI pins the toolchain deliberately for that reason.
Deliberately out of scope for now: MCP, subagents, a plugin system for slash commands, multi-provider beyond the two wire formats, and an alt-screen mode.
Known rough edges: the input box hard-wraps mid-word (a tradeoff that keeps the
cursor column correct with CJK and emoji), and /clear on a resumed session
keeps appending to the same transcript.
The file tools (read, write, edit, grep, ls) are confined to the
directory moon-code was started in, resolving symlinks first so a link out of
the project is refused rather than followed. bash is not confined and cannot
usefully be: it gets a shell. Interactively it goes through the permission
prompt; with no terminal to ask, shell commands are refused unless you opt in.
So the boundary is real but narrow: it stops a prompt injection from quietly reading or writing outside your project. It is not a sandbox. Anything with shell access has the machine, so run untrusted work in a container.
MIT
Not affiliated with, endorsed by, or connected to Anthropic. moon-code is an independent project that imitates the Claude Code terminal interface; "Claude" and "Claude Code" are Anthropic's.