# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

Swival is a minimal CLI agent that connects to LM Studio, HuggingFace Inference API, or OpenRouter via LiteLLM. It auto-discovers the loaded model (LM Studio) or uses a user-specified model (HuggingFace), sends a question, and runs an agentic tool-calling loop until the model produces a final text answer. Python 3.13+, stdlib-heavy, external dependencies are `litellm`, `tiktoken`, `html-to-markdown`, `rich`, and `prompt-toolkit`.

## Commands

```sh
uv sync                    # Install dependencies
uv run swival "your task"   # Run the agent (LM Studio, default)
uv run swival --repl                                      # Interactive session (REPL)
uv run swival "task" --base-dir ./project --max-turns 10  # Typical dev run
uv run swival "task" --base-dir ./project --allow-commands "ls,git,python3"  # With command execution
uv run swival "task" --report report.json                                     # JSON evaluation report (no stdout)
uv run swival "task" --reviewer ./review.sh                                   # External reviewer loop

# HuggingFace Inference API
export HF_TOKEN=hf_...
uv run swival "your task" --provider huggingface --model zai-org/GLM-5

# HuggingFace dedicated endpoint
uv run swival "your task" --provider huggingface --model zai-org/GLM-5 \
    --base-url https://xyz.endpoints.huggingface.cloud --api-key hf_...

# OpenRouter (via LiteLLM)
export OPENROUTER_API_KEY=sk_or_...
uv run swival "your task" --provider openrouter --model stepfun/step-3.5-flash:free

# Skills from an external directory
uv run swival "task" --skills-dir ./my-skills

# Generate config file templates
uv run swival --init-config                    # Global: ~/.config/swival/config.toml
uv run swival --init-config --project          # Project: ./swival.toml

# Tests
uv run python -m pytest tests/ -v              # Run all tests
uv run python -m pytest tests/test_tools.py -v # Run one test file
uv run python -m pytest tests/test_think.py::TestRevision::test_valid_revision_with_mode -v  # Run one test

# Website (static site build)
uv run --group website python build.py         # Build docs.md/*.md → docs/pages/*.html
```

Ruff is available as a dev dependency (no custom config):

```sh
uv run ruff check swival/          # Lint
uv run ruff format swival/ tests/  # Format
```

## Architecture

Package entry point is `swival.agent:main`. Twelve source files (plus `__init__.py`), no framework:

- **config.py** — Configuration file loading and merging. Reads TOML config from `~/.config/swival/config.toml` (global) and `<base_dir>/swival.toml` (project). Precedence: CLI flags > project config > global config > hardcoded defaults. Uses `tomllib` (stdlib). Public API: `load_config()` loads and merges both files with validation; `apply_config_to_args()` fills argparse sentinel values from config then sweeps remaining sentinels to defaults; `config_to_session_kwargs()` translates config keys to Session naming; `generate_config()` produces a commented template. Session does not auto-load config files — config loading is a CLI concern only.
- **agent.py** — CLI entrypoint and agent loop. `main()` handles `--init-config` early, then loads config files via `load_config()` + `apply_config_to_args()` before any validation. `build_parser()` uses `_UNSET` sentinels for all configurable defaults so config files can fill in values not set on the CLI. `discover_model()` queries LM Studio's `/api/v1/models`; `call_llm()` routes to LiteLLM with provider-specific prefixes (`openai/` for LM Studio, `huggingface/` for HF). The core tool-calling loop lives in `run_agent_loop()` which returns `(answer, exhausted)` and mutates the messages list in place. `repl_loop()` wraps it for interactive sessions. `handle_tool_call()` returns `(tool_msg, metadata)` tuple — metadata has stable keys `name`, `arguments`, `elapsed`, `succeeded`. At startup, discovers skills and includes them in the system prompt; passes `skills_catalog` and `skill_read_roots` through to `dispatch()`. Runtime failures raise `AgentError` instead of calling `sys.exit(1)`, allowing the report feature to capture errors. `append_history()` logs every final answer to `.swival/HISTORY.md` (append-only, 500KB cap, disabled with `--no-history`). `run_reviewer()` spawns an external reviewer executable after each answer; exit 0 accepts, exit 1 feeds stdout back as a new prompt (resetting turns, preserving context), exit 2+ accepts as-is with a warning. Capped at `MAX_REVIEW_ROUNDS` (5). Incompatible with `--repl`. The reviewer subprocess receives `SWIVAL_TASK`, `SWIVAL_REVIEW_ROUND`, and `SWIVAL_MODEL` environment variables.
- **todo.py** — `TodoState` class for tracking work items across a session. Actions: `add`, `done`, `remove`, `clear`, `list`. Items matched by text with progressive fuzzy matching (exact → prefix → substring, case-insensitive). Persists to `.swival/todo.md` as markdown checkboxes; deleted on session init (session isolation). Caps: 50 items, 500 chars per item. `done` on already-done items is a no-op. Duplicate adds are silently deduplicated — returns success with a `"note"` key, but the item is not added again. The agent loop skips generic tool logging for todo calls (same as think). Tracks usage counters and emits a summary line at loop end.
- **tools.py** — Tool definitions as OpenAI function-calling schemas (`read_file`, `write_file`, `edit_file`, `delete_file`, `list_files`, `grep`, `think`, `todo`, `fetch_url`, `run_command`, `use_skill`). The base 9 are always in `TOOLS`; `run_command` is conditionally included when `--allow-commands` or `--yolo` is provided; `use_skill` is conditionally included when skills are discovered. `dispatch()` routes tool calls and accepts `**kwargs` for extra context (`thinking_state`, `todo_state`, `resolved_commands`, `skills_catalog`, `skill_read_roots`, `file_tracker`, `tool_call_id`). `write_file` accepts an optional `move_from` parameter for renaming/moving files. When `content` is omitted, does an atomic filesystem rename (works for binary files). When `content` is provided, writes the new content then trashes the source via `_delete_file`. No read guard on the source (renaming doesn't change content), but any pre-existing destination must have been read or written first. `delete_file` soft-deletes by moving files to `.swival/trash/<uuid>/` with metadata in `.swival/trash/index.jsonl`; retention is capped at 7 days / 50 MB via `_cleanup_trash()`.
- **edit.py** — `replace(content, old_string, new_string, replace_all)` with 3-pass matching: exact → line-trimmed → Unicode-normalized (smart quotes, em dashes, ellipsis → ASCII). Raises `ValueError` for ambiguous matches, not-found, or no-op edits.
- **fetch.py** — `fetch_url(url, format, timeout)` retrieves web content as markdown (default), plain text, or raw HTML. Blocks private/internal network addresses via `ipaddress` checks. Handles redirects manually (custom `urllib.request` handler) to validate each hop against the SSRF blocklist. Output capped at 50KB. Uses `html-to-markdown` for HTML→markdown conversion.
- **thinking.py** — `ThinkingState` class for structured multi-step reasoning with revision and branching. Only `thought` is required; `thought_number`, `total_thoughts`, and `next_thought_needed` auto-default (auto-increment, carry forward, true). The schema uses a discriminated `mode` parameter (`"new"`, `"revision"`, `"branch"`) instead of cross-field booleans, with a `_sanitize()` layer that infers mode from whatever fields models send and strips incompatible fields. Tolerant coercion handles common model mistakes: template payloads with all fields set are normalized, impossible modes (revision/branch on first call) are downgraded to `"new"`, and invalid mode values fall back to `"new"`. A one-shot sanitizer tracks the last validation error and auto-retries as a minimal thought if the same error repeats. Corrective error messages include valid thought numbers and suggested fixes. Tracks `think_calls` usage counter and emits a one-line summary to stderr at loop end. Handles its own stderr logging; the agent loop skips generic tool logging for think calls.
- **fmt.py** — ANSI-formatted diagnostic output using Rich. Centralizes all stderr formatting: turn headers, LLM timing, tool call/result display, think steps, context stats, warnings, and errors. Initialized via `fmt.init()` with color preference from `--color`/`--no-color` flags.
- **skills.py** — Skill discovery and activation. Scans `base_dir/skills/` and optional `--skills-dir` paths for `SKILL.md` files with YAML frontmatter (name + description). Uses progressive disclosure: a compact catalog is included in the system prompt, and the model calls `use_skill` to load full instructions on demand. Skills outside `base_dir` get their paths added to `read_roots` so `read_file` can access their files.
- **report.py** — `ReportCollector` class that accumulates timeline events (LLM calls, tool calls, compactions, guardrails, truncated responses) and produces a JSON report. Tracks which skills were activated during the run (`skills_used`). Also defines `AgentError`, the exception used for reportable runtime failures. The collector is optional (`None` when `--report` is not given); instrumentation in the loop is gated by `if report:`.
- **tracker.py** — `FileAccessTracker` class enforcing read-before-write: the agent cannot overwrite or edit an existing file unless it was previously read via `read_file` (successful content read only — directories, binary rejections, and zero-content reads like offset-past-EOF don't count). The same guard applies to `write_file` with `move_from` for the destination — if the destination already exists it must have been read or written first. The source is not guarded (renaming doesn't change content). Files the agent created via `write_file` are re-writable without a read. Tracker state is reset on REPL `/clear` but intentionally survives context compaction. Disabled with `--no-read-guard`.
- **session.py** — Public Python API. `Session` class stores configuration as plain attributes; `.run(question)` does single-shot execution with fresh state, `.ask(question)` supports multi-turn conversations with shared history. Returns `Result` dataclass with `answer`, `exhausted`, `messages`, and optional `report` dict. Handles provider resolution, tool building, and system prompt assembly via lazy `_setup()`. Each `.run()` creates isolated `ThinkingState`, `TodoState`, and `FileAccessTracker` instances. Accepts `allowed_dirs_ro` for read-only directory access; these paths seed `skill_read_roots` per-run (each run gets its own copy).

### Sandboxing and output caps

All file operations go through `safe_resolve()` which resolves symlinks then checks `is_relative_to(base_dir)`. Additional directories can be granted read-write access via `--add-dir` (CLI) or `/add-dir` (REPL), and read-only access via `--add-dir-ro` (CLI) or `/add-dir-ro` (REPL). External skill paths and `--add-dir-ro` paths share the same `read_roots` list for read-only access. Patterns in `list_files`/`grep` reject `..` components. Output caps: 50KB per `read_file` (with pagination hints), 2000 chars per line, 100 items for listings/grep. `run_command` output over 10KB is saved to `.swival/` inside `base_dir` (auto-deleted after 10 min) and the agent paginates through it with `read_file`.

### Context management

`estimate_tokens()` uses tiktoken (cl100k_base). On context overflow, recovery proceeds in stages: `compact_messages()` truncates large tool results in older turns → `drop_middle_turns()` drops entire middle turns while preserving the leading system/user block and last 3 turns. Turns are grouped atomically (assistant + tool results) via `group_into_turns()`.

### Project instructions (CLAUDE.md / AGENTS.md)

At startup, the agent checks `base_dir` for `CLAUDE.md` and/or `AGENTS.md`. If found, their contents are appended to the default system prompt inside `<project-instructions>` and `<agent-instructions>` XML tags respectively. Both files are optional; if both exist, both are included (CLAUDE.md first). Content is capped at 10,000 characters per file. This only applies when using the default system prompt — `--system-prompt "custom"` skips instruction files. Use `--no-instructions` to disable loading entirely (recommended for untrusted repos).

### Agent loop details

- `run_agent_loop(messages, tools, **kwargs)` returns `(final_answer | None, exhausted: bool)`. It mutates `messages` in place (appends assistant/tool messages, in-place compaction via `messages[:] = ...` on overflow).
- When `finish_reason=length`, injects a user message nudging the model to continue via tools (recovers from over-long responses without counting them as a final answer).
- Guardrails: detects repeated identical tool errors (2+ consecutive) and injects corrective user messages to break the loop, escalating to a stronger STOP intervention at 3+.
- stdout is exclusively for the final answer (clean for piping). All diagnostics go to stderr via `fmt` module; use `-q`/`--quiet` to suppress all diagnostics and only print the final result.
- Exit codes: 0 = success, 1 = error, 2 = max turns exhausted.
- `litellm` is imported lazily inside `call_llm()` to keep startup and tests fast.
- REPL mode (`--repl`): runs `repl_loop()` which uses `prompt_toolkit` for input, appends user messages, and calls `run_agent_loop()` for each. Conversation history carries across questions. Type `/exit` (or `/quit`) or Ctrl-D to quit. Slash commands: `/help` (show commands), `/clear` (reset conversation), `/compact` (compress context), `/add-dir <directory>` (add read-write directory), `/add-dir-ro <directory>` (add read-only directory), `/extend [N]` (double max turns, or set to N), `/continue` (reset turn counter and continue the agent loop), `/init` (generate AGENTS.md for the current project).

## Test Organization

Tests live in `tests/` with one file per source module (e.g. `test_think.py`, `test_tools.py`, `test_edit.py`). Tests use temporary directories via pytest fixtures, mock LLM calls, and have no external service dependencies.

## Key Conventions

- Tool errors are returned as lowercase `"error: ..."` strings to the model (never crash the loop, never raise). User-facing fatal errors use uppercase `"Error: ..."` on stderr.
- Uses `urllib.request` for LM Studio REST calls (not `requests`). LiteLLM is only used for the chat completion call.
- Modern Python 3.13+ type hints (`X | Y` union syntax).
- The default system prompt lives in `swival/system_prompt.txt` (loaded via `Path(__file__).parent / "system_prompt.txt"` in `agent.py`).
- `.swival/` inside `base_dir` is the runtime scratch directory (command output files, todo list, response history, trash). It is created as needed. Command output files are auto-cleaned; `todo.md` is session-isolated (deleted on startup); `HISTORY.md` persists across sessions; `trash/` holds soft-deleted files (7-day / 50 MB retention cap).
- `plans/` contains design docs for major features (tool calling, context management, think tool, skills, etc.) — useful for understanding design rationale.
- All stderr diagnostic output goes through `fmt.py`, never raw `print(..., file=sys.stderr)`. The `fmt` module must be initialized with `fmt.init()` before use.
- Build system is hatchling (`pyproject.toml`). Use `uv` for all package/dependency management.

## Website

`build.py` at the repo root converts `docs.md/*.md` to `docs/pages/*.html`. The landing page (`docs/index.html`) and stylesheet (`docs/css/style.css`) are hand-written. Everything else under `docs/` is generated.

The `NAV` list at the top of `build.py` is the single source of truth for page order, display names, and descriptions. The build script rewrites `.md` links to `.html`, runs a broken-link checker (including `#fragment` validation), copies the logo, and generates `favicon.ico`. It exits non-zero on any broken link.

Website dependencies (`markdown`, `pillow`) are in the `website` dependency group — use `uv run --group website python build.py`.
