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
17 changes: 6 additions & 11 deletions src/semble/installer/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal
from typing import Literal

_HOME = Path.home()

Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"]
Mode = Literal["install", "uninstall"]
PathResolver = Callable[[], Path]

SEMBLE_START = "<!-- SEMBLE_START -->"
SEMBLE_END = "<!-- SEMBLE_END -->"
Expand Down Expand Up @@ -39,7 +38,7 @@
"args": ["--from", "semble[mcp]", "semble"],
}

_INSTRUCTIONS = f"""\
INSTRUCTIONS = f"""\
{SEMBLE_START}
## Semble Code Search

Expand Down Expand Up @@ -78,15 +77,11 @@
class McpConfig:
"""MCP integration config for one agent."""

path: Path | PathResolver
path: Path
key: str
entry: dict[str, object]
format: Literal["json", "toml"] = "json"

def resolved_path(self) -> Path:
"""Return the resolved config path."""
return self.path() if callable(self.path) else self.path


@dataclass(frozen=True)
class WriteResult:
Expand All @@ -110,7 +105,7 @@ class AgentTarget:

def resolved_mcp_path(self) -> Path | None:
"""Return the resolved MCP config path, or None if MCP is unsupported."""
return self.mcp.resolved_path() if self.mcp else None
return self.mcp.path if self.mcp else None


def _opencode_mcp_path() -> Path:
Expand Down Expand Up @@ -175,7 +170,7 @@ def _vscode_mcp_path() -> Path:
display_name="Opencode",
binary="opencode",
config_dir=_HOME / ".config" / "opencode",
mcp=McpConfig(_opencode_mcp_path, "mcp", _OPENCODE_SERVER_CONFIG),
mcp=McpConfig(_opencode_mcp_path(), "mcp", _OPENCODE_SERVER_CONFIG),
instructions_path=_HOME / ".config" / "opencode" / "AGENTS.md",
subagent_path=_HOME / ".config" / "opencode" / "agents" / "semble-search.md",
),
Expand All @@ -201,7 +196,7 @@ def _vscode_mcp_path() -> Path:
display_name="VS Code",
binary="code",
config_dir=None,
mcp=McpConfig(_vscode_mcp_path, "servers", _STDIO_SERVER_CONFIG),
mcp=McpConfig(_vscode_mcp_path(), "servers", _STDIO_SERVER_CONFIG),
Comment on lines 173 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Eager path resolution freezes filesystem-dependent result at import time

_opencode_mcp_path() picks between opencode.jsonc, opencode.json, and a fallback based on which files exist on disk. Calling it eagerly here means the choice is locked in at module import time. Previously it was deferred to install time (via the PathResolver callable), so it would see the actual filesystem state when the user's action is performed. In a programmatic context where the module is imported before the opencode config directory exists, the path will always default to .jsonc even if the user later has an opencode.json — the same path is used for every subsequent call within that process.

instructions_path=None,
),
AgentTarget(
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 @@ -230,7 +230,7 @@ def _strip_toml_section(text: str, header: str) -> str:
return "".join(result)


def _merge_toml_block(path: Path) -> Action:
def merge_toml_block(path: Path) -> Action:
"""Add (or refresh) the semble [mcp_servers.semble] table in a Codex config.toml as text."""
path.parent.mkdir(parents=True, exist_ok=True)
existed = path.exists()
Expand All @@ -242,7 +242,7 @@ def _merge_toml_block(path: Path) -> Action:
return "created" if not existed else "updated"


def _remove_toml_block(path: Path) -> Action:
def remove_toml_block(path: Path) -> Action:
"""Remove the semble [mcp_servers.semble] table from a Codex config.toml, leaving the rest."""
if not path.exists():
return "not-found"
Expand Down
16 changes: 8 additions & 8 deletions src/semble/installer/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@
import questionary

from semble.installer.agents import (
_INSTRUCTIONS,
AGENTS,
INSTRUCTIONS,
AgentTarget,
Mode,
WriteResult,
is_detected,
)
from semble.installer.config import (
_merge_toml_block,
_remove_toml_block,
merge_json_member,
merge_toml_block,
remove_json_member,
remove_marked,
remove_toml_block,
replace_or_append_marked,
)

Expand Down Expand Up @@ -51,24 +51,24 @@ class _Integration:
def merge_mcp(agent: AgentTarget) -> WriteResult:
"""Add the semble MCP entry to the agent's config."""
assert agent.mcp is not None
path = agent.mcp.resolved_path()
path = agent.mcp.path
return WriteResult(path, merge_json_member(path, agent.mcp.key, "semble", agent.mcp.entry))


def remove_mcp(agent: AgentTarget) -> WriteResult:
"""Remove the semble MCP entry from the agent's config."""
assert agent.mcp is not None
path = agent.mcp.resolved_path()
path = agent.mcp.path
return WriteResult(path, remove_json_member(path, agent.mcp.key, "semble"))


def _apply_mcp(agent: AgentTarget, mode: Mode) -> WriteResult | None:
"""Apply or remove the MCP server integration for one agent."""
if agent.mcp is None:
return None
path = agent.mcp.resolved_path()
path = agent.mcp.path
if agent.mcp.format == "toml":
return WriteResult(path, _merge_toml_block(path) if mode == "install" else _remove_toml_block(path))
return WriteResult(path, merge_toml_block(path) if mode == "install" else remove_toml_block(path))
return merge_mcp(agent) if mode == "install" else remove_mcp(agent)


Expand All @@ -77,7 +77,7 @@ def _apply_instructions(agent: AgentTarget, mode: Mode) -> WriteResult | None:
path = agent.instructions_path
if path is None:
return None
action = replace_or_append_marked(path, _INSTRUCTIONS) if mode == "install" else remove_marked(path)
action = replace_or_append_marked(path, INSTRUCTIONS) if mode == "install" else remove_marked(path)
return WriteResult(path, action)


Expand Down
20 changes: 10 additions & 10 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
)
from semble.installer.config import (
_CODEX_MCP_HEADER,
_merge_toml_block,
_remove_toml_block,
merge_toml_block,
remove_marked,
remove_toml_block,
replace_or_append_marked,
)
from semble.installer.installer import (
Expand Down Expand Up @@ -206,14 +206,14 @@ def test_codex_toml_merge_and_remove(tmp_path):
"""The Codex TOML helpers add/remove [mcp_servers.semble] while preserving other tables and keys."""
f = tmp_path / "config.toml"
f.write_text('model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "x"\n')
assert _merge_toml_block(f) == "updated"
assert merge_toml_block(f) == "updated"
text = f.read_text()
assert _CODEX_MCP_HEADER in text
assert 'model = "gpt-5"' in text
assert "[mcp_servers.other]" in text
assert _merge_toml_block(f) == "unchanged" # idempotent
assert merge_toml_block(f) == "unchanged" # idempotent

assert _remove_toml_block(f) == "removed"
assert remove_toml_block(f) == "removed"
text = f.read_text()
assert _CODEX_MCP_HEADER not in text
assert "[mcp_servers.other]" in text # only the semble table is removed
Expand All @@ -223,7 +223,7 @@ def test_codex_toml_merge_replaces_section_with_inline_comment(tmp_path):
"""_merge_toml_block replaces an existing semble table even when the header has a trailing comment."""
f = tmp_path / "config.toml"
f.write_text('[mcp_servers.semble] # added manually\ncommand = "old"\n')
assert _merge_toml_block(f) == "updated"
assert merge_toml_block(f) == "updated"
text = f.read_text()
assert text.count("[mcp_servers.semble]") == 1

Expand All @@ -237,14 +237,14 @@ def test_remove_toml_not_found(tmp_path, setup, expected):
f = tmp_path / "config.toml"
if setup is not None:
f.write_text(setup)
assert _remove_toml_block(f) == expected
assert remove_toml_block(f) == expected


def test_remove_toml_deletes_file_when_only_semble(tmp_path):
"""_remove_toml_block unlinks the file when removing semble leaves it empty."""
f = tmp_path / "config.toml"
_merge_toml_block(f)
_remove_toml_block(f)
merge_toml_block(f)
remove_toml_block(f)
assert not f.exists()


Expand All @@ -265,7 +265,7 @@ def test_remove_toml_strips_sub_tables(tmp_path, content):
"""_remove_toml_block removes sub-tables like [mcp_servers.semble.tools.search], before or after the main header."""
f = tmp_path / "config.toml"
f.write_text(content)
assert _remove_toml_block(f) == "removed"
assert remove_toml_block(f) == "removed"
text = f.read_text()
assert "[mcp_servers.semble]" not in text
assert "[mcp_servers.semble.tools.search]" not in text
Expand Down
Loading