Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
"git+https://github.com/nickroci/ultan@main",
"ultan",
"mcp"
]
],
"env": {
"CLAUDE_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
"CLAUDE_PLUGIN_DATA": "${CLAUDE_PLUGIN_DATA}"
}
}
}
}
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,23 @@ Inside Claude Code:
> hundred MB) in the background, and you can keep working while it finishes.

That's it. Skills and slash commands hot-load the instant you install; `/reload-plugins`
pulls in the hooks and the MCP server. **A full Claude Code restart is not required** — a
fresh session also works, but you don't need one.
pulls in the hooks and the MCP server. **A full Claude Code restart is not required** —
your next prompt kicks off provisioning automatically (a fresh session works too; you
don't need one).

> 🩺 **Wondering what it's doing? Ask Claude to run `ultan doctor`.** It reports
> whether the background install is still running, the daemon's state (warming /
> healthy / idle), priming latency, and capture freshness — at any stage, even
> mid-install.

On first use a `SessionStart` hook provisions that retrieval stack into the plugin's
private storage **in the background**. Until it finishes, priming falls back to a fast
lexical scan; after that the daemon **lazy-starts on demand**. Models download anonymously
from HuggingFace — see *First-start expectations* below.
The plugin provisions that retrieval stack into its private storage **in the
background**, triggered by whichever happens first: the plugin's MCP server starting
(right after install/reload — the spec has no install-time script, so this is the
earliest the plugin gets to run), a fresh session's `SessionStart`, or your next
prompt. All three funnel into one lock-guarded installer, so they never race. Until
it finishes, priming falls back to a fast lexical scan; after that the daemon
**lazy-starts on demand**. Models download anonymously from HuggingFace — see
*First-start expectations* below.

You now have:

Expand Down
6 changes: 3 additions & 3 deletions bin/ultan
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ if [ -f "$LOG" ]; then
echo "ultan: a new Claude Code session retries automatically." >&2
exit 1
fi
echo "ultan: not provisioned yet — the plugin installs Ultan in the background at" >&2
echo "ultan: session start (progress lands in $DATA/install.log)." >&2
echo "ultan: if this is a fresh install, start a new session or wait a minute." >&2
echo "ultan: not provisioned yet — the plugin installs Ultan in the background," >&2
echo "ultan: triggered by session start OR your next prompt (progress lands in" >&2
echo "ultan: $DATA/install.log). Send any message and try again in a minute." >&2
exit 1
2 changes: 1 addition & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"hooks": [
{
"type": "command",
"command": "[ -x \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" ] && \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" hook user-prompt-submit || true",
"command": "if [ -x \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" ]; then \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" hook user-prompt-submit; else \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-ultan.sh\"; fi || true",
"timeout": 5
}
]
Expand Down
62 changes: 62 additions & 0 deletions tests/test_mcp_provision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""The MCP server start is the plugin's earliest code execution after
/plugin install — _kick_provisioner must fire the background installer
exactly when the runtime is missing, and never otherwise."""

from pathlib import Path

import pytest

from ultan import _mcp


@pytest.fixture()
def spawn_spy(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
calls: list[list[str]] = []

class _FakeProc:
pass

def _fake_popen(cmd: list[str], **kwargs: object) -> _FakeProc:
calls.append(cmd)
return _FakeProc()

monkeypatch.setattr(_mcp.subprocess, "Popen", _fake_popen)
return calls


def _plugin_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
root = tmp_path / "plugin-root"
(root / "scripts").mkdir(parents=True)
(root / "scripts" / "ensure-ultan.sh").write_text("#!/bin/bash\n", encoding="utf-8")
data = tmp_path / "plugin-data"
data.mkdir()
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(root))
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(data))
return root, data


def test_kick_fires_installer_when_unprovisioned(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
) -> None:
root, _data = _plugin_dirs(tmp_path, monkeypatch)
_mcp._kick_provisioner()
assert spawn_spy == [["bash", str(root / "scripts" / "ensure-ultan.sh")]]


def test_kick_noop_when_already_provisioned(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
) -> None:
_root, data = _plugin_dirs(tmp_path, monkeypatch)
(data / "bin").mkdir()
(data / "bin" / "ultan").write_text("#!/bin/bash\n", encoding="utf-8")
_mcp._kick_provisioner()
assert spawn_spy == []


def test_kick_noop_without_plugin_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
) -> None:
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "nope"))
_mcp._kick_provisioner()
assert spawn_spy == []
47 changes: 44 additions & 3 deletions ultan/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,51 @@

from __future__ import annotations

import os
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP


def _kick_provisioner() -> None:
"""Fire the plugin's background installer if the runtime isn't provisioned.

The plugin spec has no install-time script, but Claude Code starts this
MCP server right after `/plugin install` + `/reload-plugins` — the
earliest code the plugin gets to run — so kicking here makes provisioning
start at install time in practice. Idempotent and cheap: ensure-ultan.sh
returns immediately and holds an atomic install lock, so overlapping
kicks from the MCP server, SessionStart, and the first-prompt fallback
all collapse into one install. plugin.json passes CLAUDE_PLUGIN_ROOT /
CLAUDE_PLUGIN_DATA into our env; missing vars degrade to a no-op (the
other triggers still cover provisioning)."""
data = os.environ.get("CLAUDE_PLUGIN_DATA") or str(
Path.home() / ".claude" / "plugins" / "data" / "ultan-ultan"
)
if (Path(data) / "bin" / "ultan").exists():
return # already provisioned
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
if not root:
return
script = Path(root) / "scripts" / "ensure-ultan.sh"
if not script.exists():
return
try:
subprocess.Popen(
["bash", str(script)],
env={**os.environ, "CLAUDE_PLUGIN_DATA": data},
stdout=subprocess.DEVNULL, # the installer logs to install.log itself
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
pass


def build_server() -> "FastMCP":
"""Construct the FastMCP server and register tools. Pure construction — no
daemon spawn, no I/O — so it's unit-testable."""
Expand Down Expand Up @@ -48,11 +87,13 @@ def ultan_recall(query: str) -> str: # pyright: ignore[reportUnusedFunction] #


def serve() -> int:
"""Run the MCP server over stdio (what Claude Code launches). Async-spawns
the daemon first so memory comes up in the background without blocking the
MCP handshake or hitting Claude Code's server-startup timeout."""
"""Run the MCP server over stdio (what Claude Code launches). Kicks the
plugin provisioner if the runtime is missing (closest thing to an
install-time hook — see _kick_provisioner), then async-spawns the daemon;
neither blocks the MCP handshake or Claude Code's server-startup timeout."""
from . import _daemon

_kick_provisioner()
_daemon.ensure_running()
build_server().run()
return 0
Loading