Development
Requirements
| Tool | Version | Why |
|---|---|---|
| Python | 3.13+ | StrEnum, override, modern generics |
| uv | 0.7+ | workspace resolution and the committed lock file |
| Git | 2.30+ | worktrees |
Everything else is installed by uv sync.
Setup
git clone https://github.com/theurian/theurian
cd theurian
uv sync # workspace + all extras + dev tools
uv run pytest # ~275 tests, offline, a couple of seconds
No API key. No network. No account. If any of those become necessary to run the test suite, that is a bug (ADR-0009).
Everyday commands
uv run pytest # everything
uv run pytest -m unit # fast subset
uv run pytest -m contract # against the installed CLI
uv run pytest --cov # with the 80% gate
uv run pytest -k scope_isolation # by name
uv run ruff format packages tests
uv run ruff check packages tests
uv run mypy # strict
uv run theurian version --json
Before opening a pull request, run the same four commands CI runs: format check, lint, mypy, pytest with coverage.
Layout
packages/theurian-core/src/theurian/
├── cli/ composition root — Typer
├── mcp/ composition root — MCP tools
├── daemon/ composition root — HTTP, lifecycle, locking
├── application/ use cases; depends on domain only
├── domain/ entities, value objects, invariants
│ └── ports/ 14 Protocols
├── infrastructure/ adapters (sqlite, vector, raptor, git, github, fs, secrets)
├── migrations/ schema migrations for the derived store
├── ingestion/ normalization/ indexing/ retrieval/
├── review/ specification/ traceability/
├── security/ path containment, input limits
└── observability/ tracing and metrics
The rules, and why they are tests
flowchart TB
I["cli / mcp / daemon"] --> A["application"] --> D["domain"]
INF["infrastructure"] -.implements.-> D
I -.wires.-> INF
style D fill:#1f6f4a,color:#fff
style INF fill:#5a3a7a,color:#fff
domain/imports nothing fromapplication/orinfrastructure/application/depends on ports, never adapters- only
cli/,daemon/, andmcp/may name a concrete adapter sqlite_vecstays insideinfrastructure/;mcpinsidemcp/anddaemon/- no vendor name in
domain/orapplication/ - no file under
plugins/importstheurian
All enforced by tests/unit/test_layering.py and test_plugin_boundary.py,
which walk the real import graph, plus a banned-import lint rule. A rule that
lives only in a document gets violated within a quarter.
Breaking one of these needs an ADR, not a # noqa.
Adding a port
Rare — the port set is closed and adding to it requires an ADR (ADR-0003). When it is genuinely warranted:
- Write the ADR first: what substitution does this enable, and what breaks without it?
- Define the
Protocolindomain/ports/. - Add it to
ALL_PORTSindomain/ports/__init__.py. - Write a deterministic fake in
tests/fakes/. A port without a fake is not finished, because it cannot be exercised offline. - Write a real adapter in
infrastructure/. - Wire it in a composition root — nowhere else.
Adding a source parser
No domain or application change is needed; that is the point of the port.
- Implement
SourceParserininfrastructure/filesystem/parsers/. - Enforce the input limits from
theurian.security— parsers never trust input. - Use safe loaders (
yaml.safe_load, neveryaml.load). - Never fetch an external
$ref; record it unresolved (SSRF, SEC-10). - Preserve structure in
NormalizedDocument.structured. Extracting text only is what makes coverage and drift detection impossible later. - Register it in the composition root.
Testing
| Marker | Scope |
|---|---|
unit |
pure, fast, no filesystem or network |
integration |
SQLite, filesystem, or a local subprocess |
contract |
a published contract, exercised through the installed CLI |
e2e |
the real CLI, daemon, or plugin end to end |
Conventions:
- ≥80% line and branch coverage, enforced in CI.
- Name the behaviour, not the method:
test_symlink_pointing_outside_root_is_refused, nottest_resolve_2. - A security control needs a test proving the control fires, not only that the happy path works.
- Contract tests run against the installed binary. Importing Core would pass even if the console script or packaging were broken.
- No test calls an external service. Ever.
Determinism
Clock and IdGenerator are ports specifically so tests can freeze time and
seed identifiers. Without that, "the same migrations produce the same state
hash" (ADR-0007) is not
assertable.
In library code, never call datetime.now() or generate a ULID directly. Inject
the port. Ruff's DTZ rules catch naive datetimes, which silently compare wrong
across a DST boundary — and validity windows depend on those comparisons.
Working on the plugin
The plugin is shell and Markdown by design.
uv run pytest packages/theurian-core/tests/unit/test_plugin_boundary.py -v
shellcheck --severity=warning plugins/claude-code/scripts/*.sh
claude plugin validate ./plugins/claude-code --strict # if the CLI is available
To test locally, point a marketplace at your checkout:
/plugin marketplace add /path/to/theurian/plugins
Running the daemon on a development machine
The --port 7420 convention below is for runs on the maintainer machine, which
keeps a resident dogfood daemon on the default 7419. A contributor machine
without one has nothing to avoid, but the same convention keeps a stray run
detectable, so these instructions assume it.
On the maintainer machine, dev-time daemon runs take --port 7420, leaving the
default 7419 for the resident dogfood daemon. While no resident daemon exists,
anything answering on 7419 is an accident; once one owns 7419, every CLI command
below defaults to it, so a dev invocation must pass --port 7420 or it will
describe — or act on — the resident daemon instead. Six commands default to 7419
(verified against source and the released dev7 wheel's --help, 2026-08-20):
| Command | Symbol | Where the 7419 default comes from |
|---|---|---|
setup --dry-run |
setup_command |
PortOption = DEFAULT_PORT |
doctor |
doctor_command |
PortOption = DEFAULT_PORT |
uninstall --dry-run |
uninstall_command |
PortOption = DEFAULT_PORT |
auth rotate |
auth_rotate |
DEFAULT_PORT |
daemon start |
daemon_start |
a literal 7419 |
daemon status |
daemon_status |
a literal 7419 |
The first three are in cli/setup_commands.py, then cli/auth_commands.py, then
cli/commands.py; DEFAULT_PORT is 7419 in daemon/instance.py. Named by
symbol, not line, because a line number rots on the next edit. Re-count with
grep -rn 'PortOption\|DEFAULT_PORT\|7419' packages/theurian-core/src/theurian/cli/
rather than trusting this table.
uninstall is the one that bites: with --dry-run mandated for it, a forgotten
--port produces a removal plan for the resident daemon that reads exactly
like a plan for the dev one. daemon stop takes no --port at all, and adding
one would not help — it asks the service manager which daemon it owns rather than
probing a port (ADR-0002;
a PID-based kill can signal an unrelated recycled PID). So theurian daemon stop
stops the resident daemon; a dev daemon is started with --foreground and
stopped with Ctrl-C in its terminal.
Dependencies
Every dependency is pinned with ==, uv.lock is committed, and CI runs
uv sync --frozen (ADR-0014).
Adding one:
- Check the current version on PyPI — pin it, do not recall it.
- Pin exactly in the right
pyproject.toml. - If it is pre-1.0, it needs an ADR naming the port that contains it and the fallback if it is abandoned.
- Run
uv syncand commituv.lock. - Confirm the licence is Apache-2.0-compatible; CI will fail on copyleft.
Commits
Conventional Commits, DCO sign-off, a verified commit signature, one topic per pull request. Full detail in CONTRIBUTING.md.
git config commit.template .gitmessage # optional, prompts for both
Troubleshooting
| Symptom | Cause |
|---|---|
ModuleNotFoundError: theurian |
Run uv sync, or prefix with uv run |
| mypy complains about a missing stub | Add types-* to the dev group and pin it |
| A contract test skips | theurian is not on PATH; uv sync installs it |
| Coverage below 80% | New code without tests — that is the gate working |
| A layering test fails | An import crossed a boundary; see ADR-0003 |