# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash pip install -e ".[dev]" # dev install scripts/check.sh # full local CI gate (ruff + mypy + pytest) — mirrors .forgejo/workflows/ci.yml python -m pytest -q # tests python -m pytest tests/test_prompt_urls.py -q # one file python -m pytest tests/test_actions.py::test_do_start -q # one test ruff check src/ tests/ build_archive.py mypy # config in pyproject.toml (files = src/llamacppctl) ``` Always use `python -m pytest`, never the `pytest` console script: the test modules do `from tests.test_docker_ops import ...`, which needs the repo root on `sys.path`. `git config core.hooksPath .githooks` (once per clone) enables the pre-push hook that runs `scripts/check.sh`. CI runs on Python 3.10 and 3.12; `requires-python = ">=3.10"`. Other entry points: - `python3 build_archive.py` — builds and self-verifies `llamacppctl-installable.tar.gz` (has its own required-file manifest; adding a top-level file that must ship means updating it). - `SMOKE_GPU=1 scripts/smoke.sh` — opt-in end-to-end test against a real Docker + GPU + GGUF model. Not part of the pytest suite. ## Architecture A single CLI (`llamacppctl`) that starts/checks/stops/switches llama.cpp servers running as Docker containers, plus a security-hardened input layer for system/user prompts from text, file, or URL. `main.py` is a thin pipeline: parse (`cli.build_parser`) → validate (`cli.validate_args`) → `docker_available()` → resolve prompt sources (`prompt_io`) → resolve config (`config.resolve_effective_config`) → dispatch to `actions.do_*`. Layering is deliberate and must be preserved — it keeps the security-critical input validation independently testable from orchestration: - `prompt_io.py` knows nothing about argparse; it works only with `PromptSource` + `InputPolicy`. - `config.py` knows nothing about Docker or HTTP. - `docker_ops.py` / `http_ops.py` know nothing about CLI semantics. - `actions.py` orchestrates; `schema.py` holds the shared dataclasses (`ServerConfig`, `PromptConfig`, `ChatReply`, `CheckResult`). Two cross-module couplings to know about: - `main._resolve_prompts()` stashes loaded prompts on the argparse namespace as `args._resolved_system_prompt` / `args._resolved_user_prompt`; `config.py` picks them up there. - `main.run()` calls `docker_available()` *before* config resolution, so `--dry-run` still needs a reachable Docker daemon. Only `--print-effective-config` skips that check — it is a standalone action in the mutually-exclusive group, never a modifier, so it cannot start anything. ### Config resolution INI file (`llama.cpp.config`, template in `llama.cpp.config.example`), lowest to highest priority: builtin defaults (`config.builtin_defaults()`) → `[default]` → `[model.]` (via `--profile`) → CLI overrides. `[prompt.]` sections resolve separately and only supply a *fallback* system prompt — an explicit `-s`/`--system-file`/`--system-url` always wins. `container_name` is mandatory (empty → `ConfigError`). It is the only identity anchor: Docker `--name`, the `--stop`/`--check` target, and the derived lock path `/tmp/llamacppctl..lock`. Profiles meant to run in parallel need distinct `container_name` *and* `host_port`, otherwise a second `--start` silently replaces the first container. `max_tokens` / `chat_temperature` / `connect_timeout` / `read_timeout` affect only the chat request (`--chat` and the reply printed after `--start`), never the container. ### Exit codes (`actions.py`) `0` ok · `1` general/config/prompt error · `2` argparse validation · `3` model path missing · `4` docker error · `5` HTTP not ready (also `--check` failing) · `6` lock busy. `--check` is meant to be scriptable; keep those codes stable. ## Invariants worth not breaking - All Docker calls go through `subprocess.run()` with **argument lists** — never `shell=True`, never composed shell strings. - The port publishes to `127.0.0.1` by default (`docker_ops._port_publish()`); the llama.cpp OpenAI endpoint is unauthenticated unless `api_key` is set. `--expose` binds all interfaces. - Every HTTP request in `prompt_io` — including each redirect hop — passes `validate_url_target()`: HTTPS-only, no IP literals, no embedded credentials, no `localhost`, all resolved addresses classified (private/loopback/link-local/multicast/reserved/unspecified → reject), metadata IP `169.254.169.254` blocked. `_pin_dns()` then restricts `getaddrinfo` to the already-validated IPs for the duration of the request, closing the TOCTOU/DNS-rebinding window. Size limits are enforced both from `Content-Length` and while streaming. - Escape hatches (`--allow-private-url`, `--allow-insecure-http`, `--allow-ip-host`, `--allow-symlinks`, `--follow-redirects`, `--allow-html-input`) are opt-in and off by default. `--allow-private-url` and `--url-allow-host` are mutually exclusive. - `--start` and `--change` hold an exclusive non-blocking `fcntl.flock`; `--change` validates the model path *before* removing the running container. `--dry-run` takes no lock and touches nothing. Tests mock `subprocess` and `requests` (and DNS resolution for the SSRF cases); nothing in the pytest suite requires Docker or the network. ## Conventions - Docs, comments in `docs/`, and the example prompts are in German; source code, docstrings, and commit subjects are in English. Commits follow Conventional Commits (`feat(chat):`, `docs(prompts):`). - Line length 100, `from __future__ import annotations` in every module, `py.typed` is shipped — keep annotations complete enough for `mypy` to pass. - Behavior changes should be reflected in `man/llamacppctl.1`, `CHANGELOG.md`, and — for security- relevant ones — `docs/SECURITY_AND_OPERATIONS.md`.