llamacppctl/CLAUDE.md

104 lines
5.8 KiB
Markdown
Raw Permalink Normal View History

# 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.<profile>]` (via `--profile`)
→ CLI overrides. `[prompt.<name>]` 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.<container_name>.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`.