Development Setup
Use main for development only. The main branch is the active integration branch and is not considered stable; released versions are published through PyPI and GitHub release tags after checks pass.
Clone the repository and set up the development environment:
git clone git@github.com:linuxarena/control-tower.git cd control-tower make init
make init runs uv sync --locked --dev --all-extras, creates .env from .env.example, activates the repo git hooks, and offers to install shell tab completions and an alias ct='uv run ct'.
Edit .env to add at least one LLM API key, such as ANTHROPIC_API_KEY or OPENAI_API_KEY.
Generate the model rate table so eval costs are priced rather than unknown:
uv run python scripts/generate_model_rates.py
The file lands at ~/.cache/control-tower/model_rates.json. Override the path with CONTROL_TOWER_MODEL_RATES in .env if needed.
Re-running the script merges into that file rather than replacing it: published rates are refreshed, new models are added, and a model the source has stopped publishing keeps the rate it last had (the script reports how many). To price a model the source does not carry, add the row by hand and mark it "manual": true — the script leaves those rows alone. It also refuses to write a table the reader could not load back, so a typo in a hand-added row is reported rather than taking the whole table down.
{ "model": "our-internal-model", "manual": true, "pricing": {"input_per_million": 3.0, "output_per_million": 15.0, "cached_input_per_million": 0.3} }
Running checks
Run make check for the full gate or make format to apply formatting fixes.
uv run pytest uses the committed fixture setting. Use uv run pytest --installed-settings for local settings or uv run pytest --installed-settings -m integration for integration tests. EC2 tests using instance-profile credentials require AWS_EC2_METADATA_DISABLED=false.
Pulling Settings
Settings are directories under .settings/ that group one or more environments—realistic software systems that agents are evaluated in (e.g., a Flask API, a Clojure job scheduler, a C++ network monitor). The .settings/ directory will be empty after a fresh clone. Each setting is one repo under the linuxarena GitHub org, and decides for itself which environments it contains; you can also create your own. To pull official ones:
uv run ct settings pull # every tracked setting uv run ct settings pull linuxarena # one setting uv run ct settings pull linuxarena@v1.1.0 # pin it per-machine
Check installed environments and their status:
uv run ct settings status
ct settings pull clones public repos anonymously with git clone and falls back to gh repo clone for private ones, so the GitHub CLI (gh) must be installed and authenticated only when you need those. A setting that fetches environments of its own declares that step as [tool.control_tower] install in its pyproject.toml, and the pull runs it after cloning; LinuxArena reads CT_LINUXARENA_TIER there to choose which tiers to install (public by default; all, or a list like public,extras).
Tracked settings are defined in src/control_tower/environments/settings.yml, one repo per setting. Per-machine pins and links live in .settings.local.yml at the repo root.
Local changes are protected at two layers. ct settings pull never touches a setting whose working tree has uncommitted changes or untracked files — commit, stash, or ct settings remove it to resume updates. The environments inside a setting belong to the setting's install step, which runs on every pull: LinuxArena's sync.py leaves a dirty environment clone alone and reports it.
Machine-level store. ct settings pull --global pulls into ~/.cache/control-tower/settings (honoring XDG_CACHE_HOME) instead of this checkout's .settings/. A checkout with no .settings/ of its own resolves environments from that store, so new checkouts and git worktrees — of Control Tower or of any consumer repo — need no per-checkout pull while the store is current. A local .settings/ always wins over the store, and CONTROL_TOWER_SETTINGS_DIR overrides both. Every ct settings command follows the same resolution, so in a checkout without .settings/ a plain ct settings pull updates the store too; create an empty .settings/ first to give a checkout its own copies.
Pinning. ct settings pull <setting>@<ref> pulls at that tag/branch/SHA and records the pin per-machine — every later pull keeps the setting there until you release it:
uv run ct settings pull linuxarena@v1.1.0 # pin a setting uv run ct settings pull linuxarena@default # release, back to the declared ref
Team-wide pins are ref: edits in settings.yml, reviewed in a PR like any other change. (Pinning code does not pin the prebuilt Docker image — set ENV_IMAGE_TAG_<ENV> in .env for that.)
Local environments (no GitHub repo needed): ct settings pull ./my_env symlinks the directory into .settings/ and records the link, so it survives future pulls. The source directory is never modified. ct settings remove my_env unlinks it.
Per-machine pins and links live in .settings.local.yml (pins: / links:), written by the CLI and hand-editable.
Local Services
A single Docker Compose file spins up support services such as MongoDB, the web viewer, and the inspect logs viewer:
docker compose up -d --build
See Web Viewer, Inspect Logs Viewer, and Docent for service ports, credentials, and environment variables.
Shell Tab Completion
make init offers to install tab completion. To run the completion installer on its own:
uv run python scripts/setup_completions.py
This will install a marked block in ~/.zshrc and/or your bash rc file that will load completions when a new shell is opened.
To uninstall, run:
uv run python scripts/setup_completions.py --uninstall
Agent Skills
Domain skills (running-evals, monitoring, making-*, safety-curves, trajectories, control-concepts, validate-straj) live in .agents/skills/ and symlinked in .claude/skills/.
The dev-workflow skills (pr, babysit, ship, deliver, ct-box) ship as the control-tower-dev plugin from the control-tower-plugins marketplace — this repo's committed .claude/settings.json installs it when you trust the repo and auto-updates it at startup. Other plugins are opt-in: /plugin install control-research@control-tower-plugins. For anything beyond that (developing, publishing, or adding plugins), see that repo's README.
Codex discovers .agents/skills/ natively and consumes the same marketplace, once per machine: codex plugin marketplace add linuxarena/control-tower-plugins, then codex plugin add control-tower-dev@control-tower-plugins. Codex does not auto-update marketplace snapshots — refresh with codex plugin marketplace upgrade control-tower-plugins.
Editing control-tower from a downstream project
To iterate on control-tower while working in a downstream repo (basharena, research, etc.), from inside the downstream project root:
uv add --editable /path/to/control-tower
To switch back when done:
uv add 'control-tower @ git+https://github.com/linuxarena/control-tower.git@main'
Git Hooks
This repository has pre-commit and pre-push git hooks, activated by make init. To activate them on their own:
git config core.hooksPath .githooks
Note: The git hooks require bash 4.0 or later.
You may also add custom pre-commit and pre-push hooks by setting environment variables:
PRE_COMMIT_CUSTOM="/path/to/script.sh"PRE_PUSH_CUSTOM="/path/to/script.sh"
Pycache
Compiled python files in __pycache__ folders can interfere with git operations. To avoid this problem, store pycache files in an external folder by setting this environment variable:
export PYTHONPYCACHEPREFIX=/tmp/pycache
If you have any lingering __pycache__ folders in this directory, clean them up with
find . | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf
You can also use make clean to do the same thing.
Ruff checks locally and in CI
Use uv run ruff from the repository root, and point editor integrations at .venv/bin/ruff, to use the project's pinned version. Ruff's required-version setting rejects other versions; update it in both pyproject.toml and ops/pyproject.toml when changing the dependency pin and lockfile.
Pre-commit and CI both run uv run ruff check and uv run ruff format --check across the repository. These checks do not edit files. Run make format to apply fixes, review them, and stage the changes before committing again.
Import sorting uses explicit first-party package names and disables source-directory discovery with src = []. Leftover directories from removed packages therefore do not change import grouping. Keep the first-party lists current when adding a package.
Local hooks inspect the working tree, so stage the version of each file that passed the checks. GitHub PR checks run on the merge result with the base branch; use make github-pr-checks when you need to reproduce that full check locally.
CI reports failures through the existing checks without launching an automatic Claude fix proposal.
Installed package checks
Run make wheel-check to build a wheel and exercise a saved-run report from a clean virtual environment outside the checkout, first with base dependencies and then with the viz extra. It uses the committed recording and locked dependency versions, without Docker, a database, or model calls. PR CI runs this check when package inputs change, and publishing checks the built wheel before uploading it.