# 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 --allowed-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
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

# 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 -v  # Run one test

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

No linter is configured.

## Architecture

Package entry point is `swival.agent:main`. Nine source files, no framework:

- **agent.py** — CLI entrypoint and agent loop. `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.
- **tools.py** — Tool definitions as OpenAI function-calling schemas (`read_file`, `write_file`, `edit_file`, `delete_file`, `list_files`, `grep`, `think`, `fetch_url`, `run_command`, `use_skill`). The base 8 are always in `TOOLS`; `run_command` is conditionally included when `--allowed-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`, `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). Persistent notes saved to `.swival/notes.md` (capped, auto-cleaned on session init). Tracks usage counters (`think_calls`, `note_attempts`, `note_saves`) 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`.

### 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 `--allow-dir` (CLI) or `/add-dir` (REPL). External skill paths are added to a separate `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 / AGENT.md)

At startup, the agent checks `base_dir` for `CLAUDE.md` and/or `AGENT.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 directory to allowed paths), `/extend [N]` (double max turns, or set to N), `/continue` (reset turn counter and continue the agent loop).

## Test Organization

Tests live in `tests/` with one file per module: `test_tools.py`, `test_edit.py`, `test_think.py`, `test_run_command.py`, `test_compact.py`, `test_list_grep.py`, `test_logging.py`, `test_provider.py`, `test_instructions.py`, `test_fetch.py`, `test_fmt.py`, `test_skills.py`, `test_guardrails.py`, `test_yolo.py`, `test_allow_dir.py`, `test_repl.py`, `test_report.py`, `test_history.py`, `test_read_guard.py`, `test_reviewer.py`, `test_delete.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, persistent notes, response history, trash). It is created as needed. Command output files are auto-cleaned; `notes.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`.
