Claude Code integration
The plugin README is the user-facing guide. This page covers how the integration works and why it is shaped this way.
Two artifacts, one contract
flowchart TB
subgraph P["Claude Code plugin"]
CMD["12 commands"]
HK["SessionStart hook"]
TPL["mcp/theurian.mcp.json (template)"]
COMPAT["compatibility.yaml"]
end
subgraph PUB["Published contract — the only permitted surface"]
A1["theurian CLI, --json"]
A2["MCP over Streamable HTTP"]
A3["GET /health, /capabilities"]
A4["schemas/**.json"]
end
subgraph CORE["Theurian Core"]
INT["theurian.* internals"]
end
P --> PUB --> CORE
P -. "forbidden: import theurian" .-x CORE
style CORE fill:#5a3a7a,color:#fff
style PUB fill:#1f6f4a,color:#fff
The plugin contains no Python at all. Its scripts shell out to theurian
<verb> --json. Two steps in plugin.yml hold that over the whole plugins/
tree — a grep for import theurian and a find for *.py outside a tests/
path — and they are CI steps rather than tests, so they still fail when the
Python suite is broken. test_plugin_boundary.py asserts the same two
properties, but only for plugins/claude-code/.
That constraint is what keeps the plugin movable to its own repository, and it is also why the plugin can never quietly acquire logic that belongs in Core.
Installation flow
sequenceDiagram
participant U as User
participant CC as Claude Code
participant PL as Plugin
participant CLI as theurian CLI
participant D as Daemon
U->>CC: /plugin marketplace add
U->>CC: /plugin install theurian
CC->>PL: enable
Note over PL: Nothing happens.<br/>No daemon, no service,<br/>no MCP server declared.
U->>CC: /theurian:setup
CC->>PL: run the setup command
PL->>CLI: theurian setup --dry-run --json
CLI-->>PL: plan
PL->>U: show the plan, ask
U->>PL: approve
PL->>CLI: theurian setup --json
CLI->>CLI: data dir, token, service, project, .theurian/
CLI->>D: start
D-->>CLI: healthy
CLI->>CC: merge the MCP entry into ~/.claude.json
CLI-->>PL: report
PL->>U: what changed, what was already satisfied
Note over CC,D: Next session: every agent connects to<br/>http://127.0.0.1:7419/mcp
Why the plugin declares no MCP server
Claude Code starts a plugin's MCP servers as soon as the plugin is enabled. For
an HTTP entry that means an immediate connection attempt — nothing is spawned,
but the user's first experience is a red, failed server, before they have been
told /theurian:setup exists. That failed entry then persists as an artifact of
merely enabling the plugin, which is exactly the "installation had side effects"
property FR-L3 rules out.
So the plugin ships the definition at plugins/claude-code/mcp/theurian.mcp.json
— a path Claude Code does not scan — and /theurian:setup installs it at user
scope, merging rather than replacing.
A side benefit: the connection outlives plugin removal, so theurian stays
usable by other MCP clients, and /theurian:uninstall can remove the entry
independently of the plugin, the daemon, or the data.
Detail: ADR-0012.
What SessionStart does
Runs on every session, often several times a day, usually while the user is thinking about something else. It must therefore be cheap and boring.
flowchart TD
S["Session starts"] --> A{"theurian on PATH?"}
A -->|no| W1["warn: Core is not installed.<br/>Install it with uv tool or pipx,<br/>then run /theurian:setup"] --> Z["exit 0"]
A -->|yes| B{"compatible?"}
B -->|no| W2["warn: version mismatch, show the remedy"] --> Z
B -->|yes| C{"GET /health ok?"}
C -->|no| D{"service registered?"}
D -->|yes| E["start it — a user-approved<br/>service resuming"] --> Z
D -->|no| W3["warn: run /theurian:setup.<br/>Install nothing."] --> Z
C -->|yes| F{"repo registered?<br/>index stale?"}
F -->|needs attention| W4["one short warning"] --> Z
F -->|all good| Z2["silent"] --> Z
style Z fill:#1f6f4a,color:#fff
The theurian-absent branch names the installer before the command that needs
one. What it prints, verbatim:
Theurian: Core is not installed. Install it with: uv tool install --python 3.13 'theurian[daemon]' or: pipx install --python 3.13 'theurian[daemon]', then run /theurian:setup to configure this machine.
Printed, never run. Naming /theurian:setup on its own was advice nobody could
follow: that command shells out to the theurian binary whose absence produced
the warning.
Budget: p95 ≤ 300 ms, hard timeout 5 s, and it exits 0 unconditionally — a degraded Theurian must never stop a session from starting.
Never: install a package, register an OS service, regenerate a token, rebuild
an index, delete a database, modify a Git-tracked file, or do anything
resembling /theurian:setup.
Starting an already-registered service is allowed: that is a service the user approved, resuming. Registering one is not.
These constraints are tested by
tests/unit/test_plugin_boundary.py::test_session_start_hook_performs_no_heavy_or_mutating_work,
which greps the script for forbidden operations — a rule enforced by prose is a
rule that erodes.
Every subagent shares one daemon
Because the connection is a URL rather than a spawned process, every agent that inherits the MCP configuration reaches the same daemon. Ten subagents means ten connections to one process, sharing a warm index.
If Theurian were stdio, ten subagents would mean ten processes writing to one SQLite database and ten index builders racing. That is corruption, not slowness. (ADR-0002)
Commands
All twelve are thin adapters over the CLI and contain no Theurian logic.
/theurian:propose was the exception until Milestone 7 registered the propose
subcommand ADR-0013 describes
(#212, closing
#89); the command now shells out
to theurian propose / theurian propose accept like the rest, so the migration
format lives in Core rather than in the command document.
| Command | Underlying CLI |
|---|---|
/theurian:setup |
theurian setup [--dry-run] --json |
/theurian:status |
theurian daemon\|project\|index\|migrate status --json |
/theurian:doctor |
theurian doctor --json |
/theurian:register-project |
theurian project register --json |
/theurian:unregister-project |
theurian project unregister --json |
/theurian:index |
theurian index build --json |
/theurian:reindex |
theurian index build [--raptor] --json, theurian index gc [--dry-run] --json |
/theurian:migrate |
theurian migrate validate\|apply --json |
/theurian:ingest |
theurian ingest --json |
/theurian:propose |
theurian propose --json, theurian propose accept --json |
/theurian:upgrade |
theurian version --json, theurian compat check --json |
/theurian:uninstall |
theurian uninstall [--dry-run] --json |
/theurian:propose shells out to theurian propose to draft the proposal and
theurian propose accept to move it into place. Core writes
.theurian/proposals/<proposal-id>/; the command's own Write grant is only for
the body file it hands to --body-file. Accepting and the commands after it are
the user's: theurian migrate validate --json on the accepted migration, then
theurian migrate apply --json and theurian index build --json after the pull
request merges — --raptor on that last one where the project keeps a summary
forest, since a plain build writes no summary nodes.
The Write grant does not bound what the command may invoke. allowed-tools
grants and never removes — the semantics, with the vendor citation, are stated
once in
upgrade.md — so
Bash(theurian:*) auto-approves migrate apply, index gc, and now propose
accept (which moves a migration into .theurian/migrations/) even in the very
commands that reserve them for a human. That boundary is the command documents'
rules rather than a check Core performs
(#209).
Setup logic exists once, in SetupService. /theurian:setup and theurian
setup are the same code path with different presentation — duplicating it in the
plugin would guarantee the two drift.
Compatibility
The plugin declares its supported range in compatibility.yaml; Core performs
the comparison, so nothing reimplements SemVer ordering or the PEP 440
translation. On a mismatch the plugin stops with an actionable message and
changes nothing.
Detail: plugin-core-compatibility.md.
Uninstalling
Eight independent scopes: plugin configuration, MCP entry, daemon service,
binary, cache and derived indexes, user settings, and repository .theurian/.
Destructive scopes default to off, --dry-run enumerates everything first, and
.theurian/ is never removed without a confirmation that names what is being
deleted.
Removing the plugin never deletes approved knowledge. It is in Git.