From 7e32d665a8575f83c464270f182cdff78701de91 Mon Sep 17 00:00:00 2001 From: stephantul Date: Wed, 22 Jul 2026 10:59:34 +0200 Subject: [PATCH 1/2] add versioning to semble --- docs/installation.md | 4 +++ src/semble/installer/agents.py | 15 +++++++---- src/semble/installer/config.py | 4 +-- src/semble/installer/installer.py | 3 ++- tests/test_installer.py | 44 +++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index ce6f79cd5..128d313b2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 91f53db6f..970fd29a6 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Literal +from semble.version import __version__ + _HOME = Path.home() Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"] @@ -25,26 +27,29 @@ class IntegrationType(str, Enum): SEMBLE_START = "" SEMBLE_END = "" +# Rerunning `semble install` after an upgrade rewrites this pin to match. +SEMBLE_PIN = f"semble[mcp]=={__version__}" + _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"""\ @@ -69,7 +74,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 diff --git a/src/semble/installer/config.py b/src/semble/installer/config.py index 3cb1cc8b8..0e10c3d78 100644 --- a/src/semble/installer/config.py +++ b/src/semble/installer/config.py @@ -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 diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 7d6cdecad..e8197b0ab 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -11,6 +11,7 @@ from semble.installer.agents import ( AGENTS, INSTRUCTIONS, + SEMBLE_PIN, AgentTarget, IntegrationType, Mode, @@ -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") diff --git a/tests/test_installer.py b/tests/test_installer.py index 1c89052f1..80d23af6a 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -8,7 +8,9 @@ from semble.installer.agents import ( _STDIO_SERVER_CONFIG, AGENTS, + INSTRUCTIONS, SEMBLE_END, + SEMBLE_PIN, SEMBLE_START, IntegrationType, _opencode_mcp_path, @@ -16,6 +18,7 @@ is_detected, ) from semble.installer.config import ( + _CODEX_MCP_BLOCK, _CODEX_MCP_HEADER, merge_json_member, merge_toml_block, @@ -34,6 +37,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" @@ -383,6 +387,46 @@ def test_apply_subagent_codex_toml(tmp_path): assert not dest.exists() +def test_semble_pin_matches_installed_version(): + """SEMBLE_PIN pins the mcp extra to the exact installed semble version, not a bare/latest spec.""" + 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") From 8b00dfcef752ef03ac827ce5ec60c0636699ce49 Mon Sep 17 00:00:00 2001 From: stephantul Date: Wed, 22 Jul 2026 11:44:33 +0200 Subject: [PATCH 2/2] address comments --- src/semble/installer/agents.py | 34 +++++++++++++++++++++-- tests/test_installer.py | 50 ++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 970fd29a6..10f475c24 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -1,5 +1,7 @@ from __future__ import annotations +import importlib.metadata +import json import os import shutil import sys @@ -7,6 +9,8 @@ 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__ @@ -27,8 +31,34 @@ class IntegrationType(str, Enum): SEMBLE_START = "" SEMBLE_END = "" -# Rerunning `semble install` after an upgrade rewrites this pin to match. -SEMBLE_PIN = f"semble[mcp]=={__version__}" + +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", diff --git a/tests/test_installer.py b/tests/test_installer.py index 80d23af6a..4a0016dc3 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1,3 +1,4 @@ +import importlib.metadata import json import sys from dataclasses import replace @@ -16,6 +17,7 @@ _opencode_mcp_path, _vscode_mcp_path, is_detected, + semble_pin, ) from semble.installer.config import ( _CODEX_MCP_BLOCK, @@ -387,9 +389,51 @@ def test_apply_subagent_codex_toml(tmp_path): assert not dest.exists() -def test_semble_pin_matches_installed_version(): - """SEMBLE_PIN pins the mcp extra to the exact installed semble version, not a bare/latest spec.""" - assert SEMBLE_PIN == f"semble[mcp]=={__version__}" +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(