Skip to content

Repository files navigation

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.

Troubleshootingdietcode 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_tool never 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_text parses the known text formats, and the recovered call is rewritten into the transcript in correct structural form. Counted separately as recovered_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_complete batched with the work gets deferred. Models often emit write + run + task_complete in a single turn, declaring the output verified before a single tool result existed. One run wrote bash into a .py file, got a SyntaxError, and claimed success in the same breath — it would have scored a false pass. The loop now feeds the results back and requires task_complete on 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 in failed_generation, so the call is salvaged from it rather than lost.
  • The shell wrapper persists the working directory between calls. Each docker exec is a fresh process, so cd /app in 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
  1. Its dataset downloader shells out to Unix rm -rf .git. Needs Git's usr/bin on PATH, or just run it on Linux.
  2. terminal-bench-core@head points at ./tasks, but the repo moved to harbor-framework/terminal-bench and renamed that directory original-tasks/. Pin ==0.1.1 (commit 91e10457b5).
  3. Windows blocker: container paths are built with pathlib.Path, so /tmp becomes \tmp and the run dies in TmuxSession.__init__ with 404 Could not find the file \tmp — before the agent is ever called. The 0.00% this produces is not a score; check total_input_tokens: null in results.json to tell "harness failed" from "agent failed".
  4. It finishes by printing output_path.absolute(), which calls os.getcwd(). On a OneDrive-backed folder over WSL's drvfs that can throw after a successful run. Passing an absolute --output-path avoids 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. Without extract_tool_calls_from_text the loop would have stopped at step 1, hello.txt would 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_loop takes an extra_tool_handlers hook for exactly this, and the hook is tested; the tool itself is deliberately left until there is a baseline score to compare against.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages