Metadata-Version: 2.4
Name: dietcode
Version: 0.2.0
Summary: A CLI coding agent that writes and runs code in a sandbox.
Author: DevPatils
License: MIT
Project-URL: Homepage, https://github.com/DevPatils/dietCode
Project-URL: Repository, https://github.com/DevPatils/dietCode
Keywords: agent,llm,cli,coding-agent,terminal-bench
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: openai>=1.40.0
Requires-Dist: rich>=13.0.0
Requires-Dist: prompt_toolkit>=3.0.0
Requires-Dist: keyring>=24.0.0
Provides-Extra: dotenv
Requires-Dist: python-dotenv>=1.0.0; extra == "dotenv"
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: python-dotenv>=1.0.0; extra == "dev"
Requires-Dist: ruff>=0.6.0; extra == "dev"
Dynamic: license-file

# CLI Coding Agent

A command-line coding agent: an agentic loop with tool-calling that reads/writes
files and runs shell commands in a Docker sandbox until a task is done. No agent
framework — raw OpenAI-compatible API calls against Groq and a hand-rolled loop.

Benchmarked against Terminal-Bench.

## Install

```bash
pipx install dietcode
```

Needs **Python 3.11+**. Docker is optional — without it, use `dietcode --here`
to work in the current directory instead of a container.

`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.

```bash
dietcode doctor         # checks Python, PATH, Docker and credentials
```

Then log in once:

```bash
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](https://console.groq.com/keys) |
| `gemini` | yes | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
| `openai` | no | [platform.openai.com](https://platform.openai.com/api-keys) |

Any OpenAI-compatible endpoint works via `--base-url` (Ollama, vLLM, OpenRouter).

Now run it from anywhere:

```bash
mkdir agent-work
dietcode --mount ./agent-work
```

The first run pulls the `python:3.11-slim` image (~150 MB); after that startup
is about a second.

<details>
<summary>Running from a checkout instead</summary>

```bash
git clone https://github.com/DevPatils/dietCode.git
cd dietCode
pip install -e ".[dev]"
python cli.py --mount ./agent-work    # same code as the installed command
```

A `.env` with `GROQ_API_KEY=...` also works when running from a checkout.

</details>

**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 | Docker isn't running — or use `dietcode --here` |
| files disappear after a run | no `--mount` — see below |

## Two ways to run

| | What it does | Safety |
| --- | --- | --- |
| `dietcode --mount ./dir` | Runs in a container; `dir` is mounted in | Containment — the agent cannot reach anything else |
| `dietcode --here` | Runs in your current directory | Consent — asks before each command |

`--here` is the one that behaves like a normal dev tool. Read-only commands
(`ls`, `cat`, `git status`) run without asking; anything that writes, deletes,
or reaches outside the directory prompts first, with `[y] yes  [a] always
allow  [n] no`. `--yes` skips all prompts and is exactly as dangerous as it
sounds.

Be clear about the difference: the container is a boundary, the prompt is a
decision. A shell command can always `cd ..`, so `--here` protects you by
showing you what is about to happen, not by making escape impossible. For code
you do not trust, use the sandbox.

## Usage

Run with no arguments for an interactive session:

```bash
dietcode --mount ./agent-work
```

One container and one conversation for the whole session — 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:

```bash
python cli.py "write a python script that prints the first 20 primes and run it"
```

By default the agent works in a throwaway container, so **anything it writes is
discarded when the run ends**. To keep the files, mount a directory — the agent
stays sandboxed, but `/workspace` is now a real folder on your machine:

```bash
python cli.py --mount ./my-project "add a test for the parser and make it pass"
```

| 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 |
| `--mount HOSTDIR[:TARGET]` | bind-mount a host directory into the sandbox so the agent's files persist (default target `/workspace`). Repeatable |
| `--local` | run on the host instead of Docker (no isolation — dev only) |
| `--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

```bash
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:

```bash
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.

<details>
<summary>Four terminal-bench 0.2.18 problems this works around</summary>

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.

</details>

`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.
