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
4 changes: 4 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ semble install --agent claude pi --type mcp subagent --yes

`semble uninstall` accepts the same flags.

### Keeping installed configuration up to date

The MCP server config, `AGENTS.md`/`CLAUDE.md` instructions, and sub-agent files that `semble install` writes all pin `uvx` to the exact semble version you have installed (`semble[mcp]==X.Y.Z`), so agents keep calling the version the instructions were written for rather than whatever is newest on PyPI. After upgrading (`uv tool upgrade semble` or `pip install --upgrade semble`), rerun `semble install`. This is idempotent and rewrites the pin (and anything else that changed) in place for every agent you select.

---

## Manual setup
Expand Down
45 changes: 40 additions & 5 deletions src/semble/installer/agents.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from __future__ import annotations

import importlib.metadata
import json
import os
import shutil
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from urllib.request import url2pathname

from semble.version import __version__

_HOME = Path.home()

Expand All @@ -25,26 +31,55 @@ class IntegrationType(str, Enum):
SEMBLE_START = "<!-- SEMBLE_START -->"
SEMBLE_END = "<!-- SEMBLE_END -->"


def semble_pin() -> str:
"""Return the uvx --from specifier for the semble MCP server.

Version-pinned for normal installs (rerunning `semble install` after an
upgrade rewrites this pin to match). For an editable or local-directory
install, pins to the local source path instead, so generated configs
launch the checkout being developed rather than the released package.
For a non-editable git install, pins to the exact installed commit, since
that source may not correspond to any released PyPI version at all.
"""
try:
raw = importlib.metadata.distribution("semble").read_text("direct_url.json")
if raw:
data = json.loads(raw)
url = data.get("url", "")
if "dir_info" in data and url.startswith("file://"):
path = url2pathname(urlparse(url).path)
return f"{path}[mcp]"
vcs_info = data.get("vcs_info", {})
if vcs_info.get("vcs") == "git" and vcs_info.get("commit_id"):
return f"git+{url}@{vcs_info['commit_id']}#egg=semble[mcp]"
except Exception:
pass
return f"semble[mcp]=={__version__}"


SEMBLE_PIN = semble_pin()

_STDIO_SERVER_CONFIG: dict[str, object] = {
"command": "uvx",
"args": ["--from", "semble[mcp]", "semble"],
"args": ["--from", SEMBLE_PIN, "semble"],
"type": "stdio",
}

_OPENCODE_SERVER_CONFIG: dict[str, object] = {
"command": ["uvx", "--from", "semble[mcp]", "semble"],
"command": ["uvx", "--from", SEMBLE_PIN, "semble"],
"type": "local", # opencode uses "local"/"remote", not "stdio"
"enabled": True,
}

_BARE_STDIO_SERVER_CONFIG: dict[str, object] = { # Windsurf: command/args only, no "type"
"command": "uvx",
"args": ["--from", "semble[mcp]", "semble"],
"args": ["--from", SEMBLE_PIN, "semble"],
}

_ZED_SERVER_CONFIG: dict[str, object] = { # Zed: command/args only, no "source"
"command": "uvx",
"args": ["--from", "semble[mcp]", "semble"],
"args": ["--from", SEMBLE_PIN, "semble"],
}

INSTRUCTIONS = f"""\
Expand All @@ -69,7 +104,7 @@ class IntegrationType(str, Enum):
semble search "save model to disk" ./my-project --top-k 10
```

The index is built on first run and cached automatically. If `semble` is not on `$PATH`, use `uvx --from "semble[mcp]" semble`.
The index is built on first run and cached automatically. If `semble` is not on `$PATH`, use `uvx --from "{SEMBLE_PIN}" semble`.

### Workflow

Expand Down
4 changes: 2 additions & 2 deletions src/semble/installer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
from tree_sitter import Node, Parser
from tree_sitter_language_pack import SupportedLanguage, download, get_parser

from semble.installer.agents import SEMBLE_END, SEMBLE_START, Action
from semble.installer.agents import SEMBLE_END, SEMBLE_PIN, SEMBLE_START, Action

JsonObjectResult = tuple[Node, bytes] | Literal["skipped", "error"]
_T = TypeVar("_T")

_CODEX_MCP_HEADER = "[mcp_servers.semble]"
_CODEX_MCP_BLOCK = '[mcp_servers.semble]\ncommand = "uvx"\nargs = ["--from", "semble[mcp]", "semble"]\n'
_CODEX_MCP_BLOCK = f'[mcp_servers.semble]\ncommand = "uvx"\nargs = ["--from", "{SEMBLE_PIN}", "semble"]\n'

_json5_parser_cache: Parser | None | bool = False # False = not yet attempted

Expand Down
3 changes: 2 additions & 1 deletion src/semble/installer/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from semble.installer.agents import (
AGENTS,
INSTRUCTIONS,
SEMBLE_PIN,
AgentTarget,
IntegrationType,
Mode,
Expand Down Expand Up @@ -96,7 +97,7 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None:
dest.parent.mkdir(parents=True, exist_ok=True)
try:
src = files("semble").joinpath(f"agents/{agent.id}{dest.suffix}").read_text(encoding="utf-8")
dest.write_text(src, encoding="utf-8")
dest.write_text(src.replace('"semble[mcp]"', f'"{SEMBLE_PIN}"'), encoding="utf-8")
except Exception:
return WriteResult(dest, "error")
return WriteResult(dest, "updated" if existed else "created")
Expand Down
88 changes: 88 additions & 0 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib.metadata
import json
import sys
from dataclasses import replace
Expand All @@ -8,14 +9,18 @@
from semble.installer.agents import (
_STDIO_SERVER_CONFIG,
AGENTS,
INSTRUCTIONS,
SEMBLE_END,
SEMBLE_PIN,
SEMBLE_START,
IntegrationType,
_opencode_mcp_path,
_vscode_mcp_path,
is_detected,
semble_pin,
)
from semble.installer.config import (
_CODEX_MCP_BLOCK,
_CODEX_MCP_HEADER,
merge_json_member,
merge_toml_block,
Expand All @@ -34,6 +39,7 @@
merge_mcp,
remove_mcp,
)
from semble.version import __version__

_BLOCK = f"{SEMBLE_START}\n## Semble\nsome instructions\n{SEMBLE_END}\n"
_BLOCK_V2 = f"{SEMBLE_START}\n## Semble\nupdated instructions\n{SEMBLE_END}\n"
Expand Down Expand Up @@ -383,6 +389,88 @@ def test_apply_subagent_codex_toml(tmp_path):
assert not dest.exists()


class _FakeDistribution:
def __init__(self, direct_url: str | None):
self._direct_url = direct_url

def read_text(self, name: str) -> str | None:
return self._direct_url if name == "direct_url.json" else None


def test_semble_pin_matches_installed_version(monkeypatch):
"""For a normal (non-editable) install, semble_pin() pins the mcp extra to the exact installed version."""
monkeypatch.setattr("semble.installer.agents.importlib.metadata.distribution", lambda name: _FakeDistribution(None))
assert semble_pin() == f"semble[mcp]=={__version__}"


def test_semble_pin_uses_local_path_for_editable_install(monkeypatch, tmp_path):
"""An editable/local-directory install pins to the local source path, not the released PyPI version."""
direct_url = json.dumps({"url": tmp_path.as_uri(), "dir_info": {"editable": True}})
monkeypatch.setattr(
"semble.installer.agents.importlib.metadata.distribution", lambda name: _FakeDistribution(direct_url)
)
assert semble_pin() == f"{tmp_path}[mcp]"


def test_semble_pin_uses_git_commit_for_non_editable_git_install(monkeypatch):
"""A non-editable `pip install git+URL` pins to the exact commit, since it may not match any PyPI release."""
direct_url = json.dumps(
{
"url": "https://github.com/example/semble.git",
"vcs_info": {"vcs": "git", "commit_id": "abc123", "requested_revision": "main"},
}
)
monkeypatch.setattr(
"semble.installer.agents.importlib.metadata.distribution", lambda name: _FakeDistribution(direct_url)
)
assert semble_pin() == "git+https://github.com/example/semble.git@abc123#egg=semble[mcp]"


def test_semble_pin_falls_back_when_distribution_lookup_fails(monkeypatch):
"""If the distribution metadata can't be read at all, semble_pin() falls back to the version pin."""

def _raise(name: str):
raise importlib.metadata.PackageNotFoundError(name)

monkeypatch.setattr("semble.installer.agents.importlib.metadata.distribution", _raise)
assert semble_pin() == f"semble[mcp]=={__version__}"


@pytest.mark.parametrize(
"config",
[
_STDIO_SERVER_CONFIG,
next(a for a in AGENTS if a.id == "opencode").mcp.entry,
next(a for a in AGENTS if a.id == "windsurf").mcp.entry,
next(a for a in AGENTS if a.id == "zed").mcp.entry,
],
)
def test_mcp_configs_pin_version(config):
"""Every uvx-based MCP server config passes the version-pinned spec, never a bare 'semble[mcp]'."""
assert SEMBLE_PIN in config.get("args", ()) or SEMBLE_PIN in config.get("command", ())


def test_instructions_pin_version():
"""The instructions block's CLI fallback line is pinned, not a bare uvx invocation."""
assert f'"{SEMBLE_PIN}"' in INSTRUCTIONS
assert '"semble[mcp]"' not in INSTRUCTIONS


def test_codex_mcp_block_pins_version():
"""The Codex TOML block embeds the same version pin as the JSON-based agent configs."""
assert SEMBLE_PIN in _CODEX_MCP_BLOCK


def test_apply_subagent_pins_version(tmp_path):
"""_apply_subagent rewrites the template's fallback uvx line with the version-pinned spec."""
dest = tmp_path / "agents" / "semble-search.md"
agent = replace(next(a for a in AGENTS if a.id == "claude"), subagent_path=dest)
_apply_subagent(agent, "install")
text = dest.read_text()
assert f'"{SEMBLE_PIN}"' in text
assert '"semble[mcp]"' not in text


def test_is_detected(monkeypatch, tmp_path):
"""is_detected returns True when binary is on PATH or config dir exists."""
agent = next(a for a in AGENTS if a.id == "claude")
Expand Down
Loading