|
14 | 14 |
|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
| 17 | +import os |
| 18 | +import subprocess |
| 19 | +from pathlib import Path |
17 | 20 | from typing import TYPE_CHECKING |
18 | 21 |
|
19 | 22 | if TYPE_CHECKING: |
20 | 23 | from mcp.server.fastmcp import FastMCP |
21 | 24 |
|
22 | 25 |
|
| 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 | + |
23 | 62 | def build_server() -> "FastMCP": |
24 | 63 | """Construct the FastMCP server and register tools. Pure construction — no |
25 | 64 | daemon spawn, no I/O — so it's unit-testable.""" |
@@ -48,11 +87,13 @@ def ultan_recall(query: str) -> str: # pyright: ignore[reportUnusedFunction] # |
48 | 87 |
|
49 | 88 |
|
50 | 89 | 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.""" |
54 | 94 | from . import _daemon |
55 | 95 |
|
| 96 | + _kick_provisioner() |
56 | 97 | _daemon.ensure_running() |
57 | 98 | build_server().run() |
58 | 99 | return 0 |
0 commit comments