A CLI coding agent that writes and runs code in a sandbox.
Project description
CLI Coding Agent
A command-line coding agent: an agentic loop with tool-calling that reads/writes files and runs shell commands until a task is done — in your own directory, asking before each change, or inside a Docker container. No agent framework — raw OpenAI-compatible API calls against Groq and a hand-rolled loop.
Benchmarked against Terminal-Bench.
Install
pipx install dietcode
Needs Python 3.11+. Docker is optional and only used by --sandbox.
pipx is recommended because it puts dietcode on your PATH and keeps its
dependencies isolated. pip install --user dietcode works too, but on Windows
you may then need to add %APPDATA%\Python\Python311\Scripts to PATH yourself.
dietcode doctor # checks Python, PATH, Docker and credentials
Then log in once:
dietcode login # pick a provider, paste a key (input is hidden)
dietcode auth # check what is configured
The key goes into your OS keychain (Windows Credential Manager, macOS
Keychain, Secret Service on Linux), falling back to a 0600 file at
~/.dietcode/credentials.json. It is never written into the project.
| Provider | Free tier | Get a key |
|---|---|---|
groq |
yes, generous | console.groq.com/keys |
gemini |
yes | aistudio.google.com/apikey |
openai |
no | platform.openai.com |
Any OpenAI-compatible endpoint works via --base-url (Ollama, vLLM, OpenRouter).
Now run it from anywhere:
cd ~/some-project
dietcode
It works in the directory you are standing in, and asks before anything that writes, deletes, or reaches outside it. Docker is not required.
Running from a checkout instead
git clone https://github.com/DevPatils/dietCode.git
cd dietCode
pip install -e ".[dev]"
python cli.py # same code as the installed command
A .env with GROQ_API_KEY=... also works when running from a checkout.
Troubleshooting — dietcode doctor diagnoses all of these:
| Symptom | Cause |
|---|---|
dietcode: command not found |
its Scripts/bin dir is not on PATH — use pipx |
no credentials for ... |
run dietcode login |
Docker errors with --sandbox |
Docker isn't running — drop the flag to work locally |
| files disappear after a run | you used --sandbox without --mount |
Two ways to run
| What it does | Safety | |
|---|---|---|
dietcode |
Works in the current directory | Consent — asks before each change |
dietcode --sandbox |
Runs inside a Docker container | Containment — cannot reach anything you did not mount |
The default asks. Read-only commands (ls, cat, git status) run
without prompting; anything that writes, deletes, or reaches outside the
directory stops first with [y] yes [a] always allow [n] no. --yes skips
every prompt and is exactly as dangerous as it sounds.
The sandbox contains. --sandbox puts everything in a container capped at
2 GB memory, 2 CPUs and 512 PIDs. Add --mount ./dir to let files persist, or
--no-network to cut it off entirely. Either flag implies --sandbox.
The difference matters: a container is a boundary, a prompt is a decision. A
shell command can always cd .., so the default protects you by showing what
is about to happen, not by making escape impossible. For code you do not
trust, use --sandbox.
Usage
Run with no arguments for an interactive session in the current directory:
dietcode
One session keeps its conversation and its files — the agent remembers
what it did on previous turns and the files it built are still there. Slash
commands: /help, /files, /cost, /sandbox, /clear (forget the
conversation, keep the files), /exit. Ctrl+C interrupts a turn without
quitting.
Or pass a task to run once and exit — used for scripting and the benchmark:
dietcode "add a test for the parser and make it pass"
To run the same thing inside a container instead, add --sandbox. Note that a
container is thrown away when the run ends, so mount a directory if you want
the files to survive:
dietcode --sandbox --mount ./my-project "add a test for the parser"
| Flag | Meaning |
|---|---|
--steps |
show step separators |
--no-stream |
wait for each reply instead of showing it as it is generated |
--subagents |
let the agent delegate self-contained work to sub-agents |
--no-context |
ignore the project's DIETCODE.md / AGENTS.md |
--provider groq|gemini|openai |
which API to use (default: your saved login) |
--base-url URL |
any OpenAI-compatible endpoint (Ollama, vLLM, OpenRouter) |
--no-network |
cut the sandbox off from the network entirely |
--max-tokens N |
hard spend ceiling per task |
--context-budget N |
trim the oldest turns above this prompt size (default 48000) |
--memory / --cpus / --pids-limit |
container resource caps (default 2g / 2 / 512) |
--cleanup |
remove every leftover agent container and exit |
--sandbox |
run inside a Docker container instead of the current directory |
--mount HOSTDIR[:TARGET] |
bind-mount a directory into the container. Implies --sandbox |
--yes |
approve every action without asking (dangerous) |
--container NAME |
attach to an existing container instead of creating one |
--image IMAGE |
sandbox image (default python:3.11-slim) |
--model NAME |
default llama-3.3-70b-versatile |
--max-iterations N |
default 12 |
--json |
print metrics as JSON |
--quiet |
only print the final result |
Exit code is 0 when the agent called task_complete, 1 otherwise.
Tests
python -m pytest # Docker tests skip if the daemon is down
python -m pytest tests/test_loop.py # one file
python -m pytest -k timeout # one test
The loop tests use a scripted fake client (tests/fake_llm.py), so the suite
needs no API key and makes no network calls.
How it works
interactive ─┐
one-shot ──┼─> agent_loop ──> execute_tool ──> Executor ──> container
tb run ──┘ (agent/loop.py) (agent/tools.py) (agent/sandbox.py)
All three entrypoints run the same loop. Interactive mode differs only in that
it passes the previous turn's messages back in as history and reuses one
container; rendering lives in agent/ui.py so the loop stays UI-free and the
benchmark can run it with no console attached.
Replies stream token by token in both human-facing modes. agent_loop(stream=…)
defaults to off, and the benchmark leaves it off deliberately: streaming
means reassembling tool calls from fragments, which is strictly more machinery
to go wrong, and a scored run gains nothing from output nobody watches. Both
transports normalize to the same Completion, so the loop itself is identical
either way.
agent_loop calls the model, executes whatever tools it asks for, feeds the
results back, and repeats until task_complete, a turn with no tool calls, or
max_iterations.
Tools: read_file, write_file, edit_file, find_files, search,
run_shell, task_complete — plus spawn_subagent behind --subagents.
edit_file replaces an exact snippet rather than rewriting the file, so a
one-line change costs one line instead of four hundred. It refuses rather than
guesses: no match, or an ambiguous match, is an error explaining what to fix.
--subagents lets the agent delegate self-contained work to a fresh agent that
shares the files but not the conversation, and reports back only a summary.
The context isolation is the point — passing the transcript back would cost as
much as doing the work inline.
Project instructions. If the working directory has a DIETCODE.md,
AGENTS.md, CLAUDE.md or .cursorrules, it is appended to the system prompt
and takes precedence over the defaults. Read from the host, so the agent can't
rewrite its own standing orders. --no-context skips it.
The only thing that differs between the CLI and the benchmark is which Executor
gets passed in, so both run identical tool code.
Notes from building it
execute_toolnever raises. Llama and Qwen emit malformed tool-call JSON, invented tool names and wrong-typed arguments often enough that treating those as exceptions would kill a run several times per benchmark. Every failure comes back as an error string the model can read and correct.- Tool calls written as prose are recovered. On the very first real run,
llama-3.3-70b emitted
<function/run_shell {...}</function>as message text rather than through the tool-calling API. The loop saw no tool calls and stopped on step 1 with the task untouched.extract_tool_calls_from_textparses the known text formats, and the recovered call is rewritten into the transcript in correct structural form. Counted separately asrecovered_tool_calls— it measures the model, not the scaffold. - File tools go through the executor, not the host filesystem. Otherwise the benchmark agent would read the host while its shell acts in the container.
task_completebatched with the work gets deferred. Models often emit write + run +task_completein a single turn, declaring the output verified before a single tool result existed. One run wrote bash into a.pyfile, got aSyntaxError, and claimed success in the same breath — it would have scored a false pass. The loop now feeds the results back and requirestask_completeon its own turn.- Schemas stay permissive where the dispatcher coerces. Groq validates tool
arguments server-side and 400s the whole generation on a mismatch; a model
sending
"timeout": "10"killed a run. The rejected text comes back infailed_generation, so the call is salvaged from it rather than lost. - The shell wrapper persists the working directory between calls. Each
docker execis a fresh process, socd /appin one command would be silently lost by the next. - Written file content is base64'd over argv, so nothing the model generates
can be reinterpreted as shell syntax. Costs a ~1MB write ceiling (
ARG_MAX).
Limits and isolation
The agent runs shell commands an LLM wrote, so containers are capped by default:
2 GB memory, 2 CPUs, 512 PIDs, plus no-new-privileges. The PID cap is what
stops a fork bomb from wedging the Docker VM rather than just failing a command.
Networking is on by default (the agent often needs pip install). Use
--no-network for untrusted work — note that combined with --mount, a
networked agent can read your mounted files and send them somewhere.
Every container is labelled, and startup sweeps ones older than 6 hours left
behind by a crash. --cleanup removes them all now. This matters because
close() only runs on a clean exit — SIGKILL leaks a container otherwise.
Long sessions trim their own history: above --context-budget tokens the oldest
turns are dropped, always keeping tool calls and their results together (splitting
a pair makes the API reject the whole request). Trimming applies to what is
sent; the full transcript is still recorded.
Benchmark
The harness does not run on Windows. Use WSL or Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh # uv brings its own Python 3.13
uv tool install terminal-bench
wsl bash scripts/benchmark.sh # hello-world
wsl bash scripts/benchmark.sh broken-python # a single task
DATASET=terminal-bench-core==0.1.1 wsl bash scripts/benchmark.sh "" # everything
Docker Desktop's WSL integration means WSL shares the same daemon — no second install. The agent itself is fine on Windows; only the harness is not.
Four terminal-bench 0.2.18 problems this works around
- Its dataset downloader shells out to Unix
rm -rf .git. Needs Git'susr/binon PATH, or just run it on Linux. terminal-bench-core@headpoints at./tasks, but the repo moved toharbor-framework/terminal-benchand renamed that directoryoriginal-tasks/. Pin==0.1.1(commit91e10457b5).- Windows blocker: container paths are built with
pathlib.Path, so/tmpbecomes\tmpand the run dies inTmuxSession.__init__with404 Could not find the file \tmp— before the agent is ever called. The0.00%this produces is not a score; checktotal_input_tokens: nullinresults.jsonto tell "harness failed" from "agent failed". - It finishes by printing
output_path.absolute(), which callsos.getcwd(). On a OneDrive-backed folder over WSL's drvfs that can throw after a successful run. Passing an absolute--output-pathavoids the call.
tb does not read .env; the adapter loads it itself, and the script exports
the key as well in case the harness's isolated environment lacks python-dotenv.
Use a fixed ~15–20 task subset while iterating — not the full suite, and not repeatedly. Groq's free tier is ~1,000 requests/day and each task burns one request per loop step.
Per-task metrics.json and transcript.json are written into the harness's
logging directory; they are the input to the failure-mode table below.
Results
Smoke test only so far — one task, which is not a score.
| Task | Result | Steps | Tokens | Notes |
|---|---|---|---|---|
hello-world |
✅ resolved | 3 | 1,806 | 1 tool call recovered from text |
The full subset run is the next step. The table below stays empty until then rather than extrapolating from a single task.
| Resolution rate | Avg steps | Avg tokens | |
|---|---|---|---|
| This agent | — | — | — |
| Terminus (reference) | — | — | — |
What the first pass showed
Both defensive mechanisms earned their place immediately. From the transcript:
- Step 1's tool call arrived as prose, not through the tool-calling API. The
recovered call is visible in the log as a synthesized id (
call_1_0) with empty content. Withoutextract_tool_calls_from_textthe loop would have stopped at step 1,hello.txtwould never have been written, and the task would have failed. - Step 2 sent
"timeout": "30"as a string. That is exactly the payload that previously drew a 400 and killed a run; the permissive schema absorbed it.
One task on one model is a smoke test, so treat recovered_tool_calls as the
interesting number here, not the pass.
Status
Built and working end to end: tool dispatch, agent loop, Docker sandbox, CLI, Terminal-Bench adapter.
First real run, "write a python script that prints the first 20 primes and run it"
on llama-3.3-70b-versatile — completed in 4 steps / 4658 tokens:
| Step | What happened |
|---|---|
| 1 | Tool call arrived as text; recovered and run. The command itself was malformed (literal \n inside python -c "...") → SyntaxError |
| 2 | Model read the error, switched to write_file, and used a proper structured tool call |
| 3 | Ran the script; correct output |
| 4 | task_complete |
Not yet done:
- A benchmark run.
- Stretch goal:
spawn_subagent— a fresh loop with isolated message history that returns only its final summary to the parent.agent_looptakes anextra_tool_handlershook for exactly this, and the hook is tested; the tool itself is deliberately left until there is a baseline score to compare against.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dietcode-0.3.0.tar.gz.
File metadata
- Download URL: dietcode-0.3.0.tar.gz
- Upload date:
- Size: 94.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a593bb8fad9c507b6cc2b62d6ce83ea60752e4adff3f8a9a03d7066b2f01f3a9
|
|
| MD5 |
fd185f2ad12affbeb564c34873b4424f
|
|
| BLAKE2b-256 |
2dc00357725df9bf25b06e38360a560123010de32c5b0067642b88840166c902
|
Provenance
The following attestation bundles were made for dietcode-0.3.0.tar.gz:
Publisher:
publish.yml on DevPatils/dietCode
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dietcode-0.3.0.tar.gz -
Subject digest:
a593bb8fad9c507b6cc2b62d6ce83ea60752e4adff3f8a9a03d7066b2f01f3a9 - Sigstore transparency entry: 2341779207
- Sigstore integration time:
-
Permalink:
DevPatils/dietCode@1116a6536c060a41b185d8cd1a6cbccdb8db1d1f -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/DevPatils
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1116a6536c060a41b185d8cd1a6cbccdb8db1d1f -
Trigger Event:
push
-
Statement type:
File details
Details for the file dietcode-0.3.0-py3-none-any.whl.
File metadata
- Download URL: dietcode-0.3.0-py3-none-any.whl
- Upload date:
- Size: 66.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
926a29e339633a3941b96cba90aea6ace525b509236e00e1ca3bf2f08332a892
|
|
| MD5 |
c61e2faf7e6e4370acd4a25ad3398af3
|
|
| BLAKE2b-256 |
a70b81648b57feb02840d1ccfc4f70d5a99688aed35f1b48a5380282740c8019
|
Provenance
The following attestation bundles were made for dietcode-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on DevPatils/dietCode
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dietcode-0.3.0-py3-none-any.whl -
Subject digest:
926a29e339633a3941b96cba90aea6ace525b509236e00e1ca3bf2f08332a892 - Sigstore transparency entry: 2341779208
- Sigstore integration time:
-
Permalink:
DevPatils/dietCode@1116a6536c060a41b185d8cd1a6cbccdb8db1d1f -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/DevPatils
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1116a6536c060a41b185d8cd1a6cbccdb8db1d1f -
Trigger Event:
push
-
Statement type: