Skip to content

Commit 0f44544

Browse files
authored
Merge pull request #26 from nickroci/fix/provision-on-first-prompt
Provision on first prompt — mid-session installs never fired SessionStart
2 parents c6a0bae + 190271e commit 0f44544

6 files changed

Lines changed: 126 additions & 14 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
"git+https://github.com/nickroci/ultan@main",
1414
"ultan",
1515
"mcp"
16-
]
16+
],
17+
"env": {
18+
"CLAUDE_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
19+
"CLAUDE_PLUGIN_DATA": "${CLAUDE_PLUGIN_DATA}"
20+
}
1721
}
1822
}
1923
}

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,23 @@ Inside Claude Code:
4747
> hundred MB) in the background, and you can keep working while it finishes.
4848
4949
That's it. Skills and slash commands hot-load the instant you install; `/reload-plugins`
50-
pulls in the hooks and the MCP server. **A full Claude Code restart is not required** — a
51-
fresh session also works, but you don't need one.
50+
pulls in the hooks and the MCP server. **A full Claude Code restart is not required**
51+
your next prompt kicks off provisioning automatically (a fresh session works too; you
52+
don't need one).
5253

5354
> 🩺 **Wondering what it's doing? Ask Claude to run `ultan doctor`.** It reports
5455
> whether the background install is still running, the daemon's state (warming /
5556
> healthy / idle), priming latency, and capture freshness — at any stage, even
5657
> mid-install.
5758
58-
On first use a `SessionStart` hook provisions that retrieval stack into the plugin's
59-
private storage **in the background**. Until it finishes, priming falls back to a fast
60-
lexical scan; after that the daemon **lazy-starts on demand**. Models download anonymously
61-
from HuggingFace — see *First-start expectations* below.
59+
The plugin provisions that retrieval stack into its private storage **in the
60+
background**, triggered by whichever happens first: the plugin's MCP server starting
61+
(right after install/reload — the spec has no install-time script, so this is the
62+
earliest the plugin gets to run), a fresh session's `SessionStart`, or your next
63+
prompt. All three funnel into one lock-guarded installer, so they never race. Until
64+
it finishes, priming falls back to a fast lexical scan; after that the daemon
65+
**lazy-starts on demand**. Models download anonymously from HuggingFace — see
66+
*First-start expectations* below.
6267

6368
You now have:
6469

bin/ultan

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ if [ -f "$LOG" ]; then
3737
echo "ultan: a new Claude Code session retries automatically." >&2
3838
exit 1
3939
fi
40-
echo "ultan: not provisioned yet — the plugin installs Ultan in the background at" >&2
41-
echo "ultan: session start (progress lands in $DATA/install.log)." >&2
42-
echo "ultan: if this is a fresh install, start a new session or wait a minute." >&2
40+
echo "ultan: not provisioned yet — the plugin installs Ultan in the background," >&2
41+
echo "ultan: triggered by session start OR your next prompt (progress lands in" >&2
42+
echo "ultan: $DATA/install.log). Send any message and try again in a minute." >&2
4343
exit 1

hooks/hooks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"hooks": [
2222
{
2323
"type": "command",
24-
"command": "[ -x \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" ] && \"${CLAUDE_PLUGIN_DATA}/bin/ultan\" hook user-prompt-submit || true",
24+
"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",
2525
"timeout": 5
2626
}
2727
]

tests/test_mcp_provision.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""The MCP server start is the plugin's earliest code execution after
2+
/plugin install — _kick_provisioner must fire the background installer
3+
exactly when the runtime is missing, and never otherwise."""
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from ultan import _mcp
10+
11+
12+
@pytest.fixture()
13+
def spawn_spy(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
14+
calls: list[list[str]] = []
15+
16+
class _FakeProc:
17+
pass
18+
19+
def _fake_popen(cmd: list[str], **kwargs: object) -> _FakeProc:
20+
calls.append(cmd)
21+
return _FakeProc()
22+
23+
monkeypatch.setattr(_mcp.subprocess, "Popen", _fake_popen)
24+
return calls
25+
26+
27+
def _plugin_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
28+
root = tmp_path / "plugin-root"
29+
(root / "scripts").mkdir(parents=True)
30+
(root / "scripts" / "ensure-ultan.sh").write_text("#!/bin/bash\n", encoding="utf-8")
31+
data = tmp_path / "plugin-data"
32+
data.mkdir()
33+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(root))
34+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(data))
35+
return root, data
36+
37+
38+
def test_kick_fires_installer_when_unprovisioned(
39+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
40+
) -> None:
41+
root, _data = _plugin_dirs(tmp_path, monkeypatch)
42+
_mcp._kick_provisioner()
43+
assert spawn_spy == [["bash", str(root / "scripts" / "ensure-ultan.sh")]]
44+
45+
46+
def test_kick_noop_when_already_provisioned(
47+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
48+
) -> None:
49+
_root, data = _plugin_dirs(tmp_path, monkeypatch)
50+
(data / "bin").mkdir()
51+
(data / "bin" / "ultan").write_text("#!/bin/bash\n", encoding="utf-8")
52+
_mcp._kick_provisioner()
53+
assert spawn_spy == []
54+
55+
56+
def test_kick_noop_without_plugin_root(
57+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
58+
) -> None:
59+
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
60+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "nope"))
61+
_mcp._kick_provisioner()
62+
assert spawn_spy == []

ultan/_mcp.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,51 @@
1414

1515
from __future__ import annotations
1616

17+
import os
18+
import subprocess
19+
from pathlib import Path
1720
from typing import TYPE_CHECKING
1821

1922
if TYPE_CHECKING:
2023
from mcp.server.fastmcp import FastMCP
2124

2225

26+
def _kick_provisioner() -> None:
27+
"""Fire the plugin's background installer if the runtime isn't provisioned.
28+
29+
The plugin spec has no install-time script, but Claude Code starts this
30+
MCP server right after `/plugin install` + `/reload-plugins` — the
31+
earliest code the plugin gets to run — so kicking here makes provisioning
32+
start at install time in practice. Idempotent and cheap: ensure-ultan.sh
33+
returns immediately and holds an atomic install lock, so overlapping
34+
kicks from the MCP server, SessionStart, and the first-prompt fallback
35+
all collapse into one install. plugin.json passes CLAUDE_PLUGIN_ROOT /
36+
CLAUDE_PLUGIN_DATA into our env; missing vars degrade to a no-op (the
37+
other triggers still cover provisioning)."""
38+
data = os.environ.get("CLAUDE_PLUGIN_DATA") or str(
39+
Path.home() / ".claude" / "plugins" / "data" / "ultan-ultan"
40+
)
41+
if (Path(data) / "bin" / "ultan").exists():
42+
return # already provisioned
43+
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
44+
if not root:
45+
return
46+
script = Path(root) / "scripts" / "ensure-ultan.sh"
47+
if not script.exists():
48+
return
49+
try:
50+
subprocess.Popen(
51+
["bash", str(script)],
52+
env={**os.environ, "CLAUDE_PLUGIN_DATA": data},
53+
stdout=subprocess.DEVNULL, # the installer logs to install.log itself
54+
stderr=subprocess.DEVNULL,
55+
stdin=subprocess.DEVNULL,
56+
start_new_session=True,
57+
)
58+
except OSError:
59+
pass
60+
61+
2362
def build_server() -> "FastMCP":
2463
"""Construct the FastMCP server and register tools. Pure construction — no
2564
daemon spawn, no I/O — so it's unit-testable."""
@@ -48,11 +87,13 @@ def ultan_recall(query: str) -> str: # pyright: ignore[reportUnusedFunction] #
4887

4988

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

96+
_kick_provisioner()
5697
_daemon.ensure_running()
5798
build_server().run()
5899
return 0

0 commit comments

Comments
 (0)