From 7b68390dca31a3f24adab20700660aaeb73bc587 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 15:38:50 +0200 Subject: [PATCH 1/4] Add zcode support --- docs/installation.md | 23 +++++++++++++++++-- src/semble/agents/zcode.md | 41 ++++++++++++++++++++++++++++++++++ src/semble/installer/agents.py | 9 ++++++++ tests/test_installer.py | 1 + 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/semble/agents/zcode.md diff --git a/docs/installation.md b/docs/installation.md index 6c7e350dd..83835da59 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,7 +21,7 @@ To undo: semble uninstall ``` -Supported agents: Claude Code, Cursor, Gemini CLI, Kiro, OpenCode, GitHub Copilot, Codex, VS Code, Windsurf, Zed, Reasonix, Pi, Command Code, and Antigravity. +Supported agents: Claude Code, Cursor, Gemini CLI, Kiro, OpenCode, GitHub Copilot, Codex, VS Code, Windsurf, Zed, Reasonix, Pi, Command Code, Antigravity, and ZCode. > **Pi prerequisite:** Pi requires the MCP extension to be installed before semble can connect. Run `pi install npm:pi-mcp-extension` once, then `semble install`. @@ -298,6 +298,24 @@ cmd mcp add --scope user semble -- uvx --from "semble[mcp]" semble +
+ZCode + +Add to `~/.zcode/settings.json`: + +```json +{ + "mcpServers": { + "semble": { + "command": "uvx", + "args": ["--from", "semble[mcp]", "semble"] + } + } +} +``` + +
+ By default the MCP server indexes only code files. To also index documentation, config, or everything, append `--content docs`, `--content config`, or `--content all` to the server command. For example, in Claude Code: ```bash @@ -350,7 +368,7 @@ If `semble` is not on `$PATH`, use `uvx --from "semble[mcp]" semble` in its plac ### Sub-agent -For harnesses that support sub-agents (Claude Code, Cursor, Gemini CLI, Kiro, OpenCode, GitHub Copilot, Codex, Reasonix, Pi, Command Code, Antigravity), you can install a dedicated `semble-search` sub-agent. Copy the appropriate file from [`src/semble/agents/`](../src/semble/agents/) to your agent's agents directory: +For harnesses that support sub-agents (Claude Code, Cursor, Gemini CLI, Kiro, OpenCode, GitHub Copilot, Codex, Reasonix, Pi, Command Code, Antigravity, ZCode), you can install a dedicated `semble-search` sub-agent. Copy the appropriate file from [`src/semble/agents/`](../src/semble/agents/) to your agent's agents directory: > **Pi prerequisite:** Pi sub-agents require the Pi agents extension. Run `pi install npm:pi-agents` once before installing. @@ -367,3 +385,4 @@ For harnesses that support sub-agents (Claude Code, Cursor, Gemini CLI, Kiro, Op | Pi | `pi.md` | `~/.pi/agents/semble-search.md` | | Command Code | `commandcode.md` | `~/.commandcode/agents/semble-search.md` | | Antigravity | `antigravity.md` | `~/.gemini/config/skills/semble-search/SKILL.md` | +| ZCode | `zcode.md` | `~/.zcode/agents/semble-search.md` | diff --git a/src/semble/agents/zcode.md b/src/semble/agents/zcode.md new file mode 100644 index 000000000..f790f4bfd --- /dev/null +++ b/src/semble/agents/zcode.md @@ -0,0 +1,41 @@ +--- +name: semble-search +description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over Grep/Glob/Read for any semantic or exploratory question. +tools: Bash, Read +--- + +Use `semble search` to find code by describing what it does or naming a symbol/identifier, instead of grep: + +```bash +semble search "authentication flow" ./my-project --max-snippet-lines 10 # first 10 lines only, concise +semble search "save_pretrained" ./my-project # full chunk content +semble search "save model to disk" ./my-project --top-k 10 # more results +``` + +Results are cached automatically on first run and invalidated when files change. + +Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config: + +```bash +semble search "deployment guide" ./my-project --content docs +semble search "database host port" ./my-project --content config +semble search "authentication" ./my-project --content all +``` + +Use `semble find-related` to discover code similar to a known location (pass `file_path` and `line` from a prior search result): + +```bash +semble find-related src/auth.py 42 ./my-project +``` + +`path` defaults to the current directory when omitted; git URLs are accepted. + +If `semble` is not on `$PATH`, use `uvx --from "semble[mcp]" semble` in its place. + +### Workflow + +1. Start with `semble search` to find relevant chunks. The index is built and cached automatically. +2. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything. +3. Navigate directly to the returned file and line. Do not re-search or grep for the same content. +4. Optionally use `semble find-related` with a promising result's `file_path` and `line` to discover related implementations. +5. Use grep only when you need every occurrence of a literal string across the whole repo (e.g., all callers of a renamed function). diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 3d51ab3b4..7538e7339 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -203,6 +203,15 @@ def _vscode_mcp_path() -> Path: instructions_path=_HOME / ".codex" / "AGENTS.md", subagent_path=_HOME / ".codex" / "agents" / "semble-search.toml", ), + AgentTarget( + id="zcode", + display_name="ZCode", + binary=None, + config_dir=_HOME / ".zcode", + mcp=McpConfig(_HOME / ".zcode" / "settings.json", "mcpServers", _STDIO_SERVER_CONFIG), + instructions_path=None, + subagent_path=_HOME / ".zcode" / "agents" / "semble-search.md", + ), AgentTarget( id="vscode", display_name="VS Code", diff --git a/tests/test_installer.py b/tests/test_installer.py index 3da295d91..3ffe95855 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -134,6 +134,7 @@ def test_merge_mcp_errors(claude_agent, content): ("pi", "mcpServers"), ("commandcode", "mcpServers"), ("antigravity", "mcpServers"), + ("zcode", "mcpServers"), ], ) def test_merge_mcp_writes_under_agent_key(tmp_path, agent_id, key): From 0bab2a7574e0453b5fa9cddd7438d4a38ff0e331 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 16:13:59 +0200 Subject: [PATCH 2/4] Add support for nested MCP --- docs/installation.md | 13 +++--- src/semble/installer/agents.py | 6 ++- src/semble/installer/config.py | 79 ++++++++++++++++++++++++++-------- tests/test_installer.py | 27 ++++++++++-- 4 files changed, 96 insertions(+), 29 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 83835da59..ce6f79cd5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -301,14 +301,17 @@ cmd mcp add --scope user semble -- uvx --from "semble[mcp]" semble
ZCode -Add to `~/.zcode/settings.json`: +Add to `~/.zcode/cli/config.json` under the nested `mcp.servers` key (or use Settings -> MCP Servers -> Full configuration mode): ```json { - "mcpServers": { - "semble": { - "command": "uvx", - "args": ["--from", "semble[mcp]", "semble"] + "mcp": { + "servers": { + "semble": { + "command": "uvx", + "args": ["--from", "semble[mcp]", "semble"], + "type": "stdio" + } } } } diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 7538e7339..80f518c85 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -208,8 +208,10 @@ def _vscode_mcp_path() -> Path: display_name="ZCode", binary=None, config_dir=_HOME / ".zcode", - mcp=McpConfig(_HOME / ".zcode" / "settings.json", "mcpServers", _STDIO_SERVER_CONFIG), - instructions_path=None, + # Confirmed by inspecting ZCode's app.asar (v3.2.4): servers live under mcp.servers. + # in ~/.zcode/cli/config.json (functions readServerMapFromJson/writeServerMapToJson). + mcp=McpConfig(_HOME / ".zcode" / "cli" / "config.json", "mcp.servers", _STDIO_SERVER_CONFIG), + instructions_path=_HOME / ".zcode" / "AGENTS.md", subagent_path=_HOME / ".zcode" / "agents" / "semble-search.md", ), AgentTarget( diff --git a/src/semble/installer/config.py b/src/semble/installer/config.py index 9881c2013..e4b8b1c62 100644 --- a/src/semble/installer/config.py +++ b/src/semble/installer/config.py @@ -103,34 +103,69 @@ def _reparse_ok(text: str) -> bool: return parser is not None and not parser.parse(text.encode("utf-8")).root_node.has_error +def _nested_skeleton(keys: list[str], member_key: str, value: dict[str, object]) -> dict[str, object]: + """Build `{keys[0]: {keys[1]: ... {member_key: value}}}`.""" + nested: dict[str, object] = {member_key: value} + for key in reversed(keys): + nested = {key: nested} + return nested + + +def _resolve_or_create_section( + obj: Node, src: bytes, section_path: list[str], member_key: str, value: dict[str, object] +) -> Node | bytes | Literal["error"]: + """Walk `section_path` from `obj`, returning the final container node. + + If a level is missing, returns the full new source with a freshly nested skeleton (containing + `member_key`/`value`) inserted in its place. Returns "error" if an existing level isn't an object. + """ + container = obj + for depth, key in enumerate(section_path): + section = _member(container, src, key) + if section is None: + skeleton = _nested_skeleton(section_path[depth + 1 :], member_key, value) + return _insert_first_member(src, container, f"{json.dumps(key)}: {json.dumps(skeleton)}") + if _value_of(section).type != "object": + return "error" + container = _value_of(section) + return container + + def merge_json_member(path: Path, section_key: str, member_key: str, value: dict[str, object]) -> Action: - """Add or update `section_key.member_key = value` in a JSON5 config file, preserving comments and formatting.""" + """Add or update `section_key.member_key = value` in a JSON5 config file, preserving comments and formatting. + + `section_key` may itself be a dot-separated path (e.g. "mcp.servers") to nest through multiple + container objects, all created on demand. + """ existed = path.exists() text = path.read_text(encoding="utf-8") if existed else "" + section_path = section_key.split(".") if not text.strip(): # missing or empty: write a clean fresh file path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({section_key: {member_key: value}}, indent=2) + "\n", encoding="utf-8") + path.write_text( + json.dumps(_nested_skeleton(section_path, member_key, value), indent=2) + "\n", encoding="utf-8" + ) return "updated" if existed else "created" located = _json5_object(text) if not isinstance(located, tuple): return located obj, src = located - section_key_json = json.dumps(section_key) - member_key_json = json.dumps(member_key) - value_json = json.dumps(value) - - section = _member(obj, src, section_key) - if section is None: - new_src = _insert_first_member(src, obj, f"{section_key_json}: {{{member_key_json}: {value_json}}}") - elif _value_of(section).type != "object": + + resolved = _resolve_or_create_section(obj, src, section_path, member_key, value) + if resolved == "error": return "error" - elif (existing := _member(_value_of(section), src, member_key)) is not None: - val_node = _value_of(existing) - new_src = src[: val_node.start_byte] + value_json.encode("utf-8") + src[val_node.end_byte :] + if isinstance(resolved, bytes): + new_src = resolved else: - new_src = _insert_first_member(src, _value_of(section), f"{member_key_json}: {value_json}") + member_key_json = json.dumps(member_key) + value_json = json.dumps(value) + if (existing := _member(resolved, src, member_key)) is not None: + val_node = _value_of(existing) + new_src = src[: val_node.start_byte] + value_json.encode("utf-8") + src[val_node.end_byte :] + else: + new_src = _insert_first_member(src, resolved, f"{member_key_json}: {value_json}") new_text = new_src.decode("utf-8") if new_text == text: @@ -142,7 +177,10 @@ def merge_json_member(path: Path, section_key: str, member_key: str, value: dict def remove_json_member(path: Path, section_key: str, member_key: str) -> Action: - """Remove `section_key.member_key` from a JSON5 config file, leaving everything else intact.""" + """Remove `section_key.member_key` from a JSON5 config file, leaving everything else intact. + + `section_key` may itself be a dot-separated path (e.g. "mcp.servers"), mirroring `merge_json_member`. + """ if not path.exists(): return "not-found" @@ -151,10 +189,13 @@ def remove_json_member(path: Path, section_key: str, member_key: str) -> Action: return located obj, src = located - section = _member(obj, src, section_key) - if section is None or _value_of(section).type != "object": - return "not-found" - member = _member(_value_of(section), src, member_key) + container = obj + for key in section_key.split("."): + section = _member(container, src, key) + if section is None or _value_of(section).type != "object": + return "not-found" + container = _value_of(section) + member = _member(container, src, member_key) if member is None: return "not-found" diff --git a/tests/test_installer.py b/tests/test_installer.py index 3ffe95855..1c89052f1 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -17,7 +17,9 @@ ) from semble.installer.config import ( _CODEX_MCP_HEADER, + merge_json_member, merge_toml_block, + remove_json_member, remove_marked, remove_toml_block, replace_or_append_marked, @@ -124,6 +126,22 @@ def test_merge_mcp_errors(claude_agent, content): assert merge_mcp(claude_agent).action == "error" +def test_merge_and_remove_json_member_nested_section_key(tmp_path): + """A dotted section_key (e.g. ZCode's "mcp.servers") creates missing intermediate levels on demand.""" + f = tmp_path / "config.json" + + f.write_text('{\n "mcp": {\n "other": true\n }\n}\n') # only the outer level exists + assert merge_json_member(f, "mcp.servers", "semble", _STDIO_SERVER_CONFIG) == "updated" + data = json.loads(f.read_text()) + assert data["mcp"]["other"] is True + assert data["mcp"]["servers"]["semble"] == _STDIO_SERVER_CONFIG + + assert remove_json_member(f, "mcp.servers", "semble") == "removed" + data = json.loads(f.read_text()) + assert "semble" not in data["mcp"]["servers"] + assert data["mcp"]["other"] is True + + @pytest.mark.parametrize( ("agent_id", "key"), [ @@ -134,15 +152,18 @@ def test_merge_mcp_errors(claude_agent, content): ("pi", "mcpServers"), ("commandcode", "mcpServers"), ("antigravity", "mcpServers"), - ("zcode", "mcpServers"), + ("zcode", "mcp.servers"), # dotted key: nested two levels deep ], ) def test_merge_mcp_writes_under_agent_key(tmp_path, agent_id, key): - """merge_mcp writes the semble entry under each agent's own top-level MCP key.""" + """merge_mcp writes the semble entry under each agent's own (possibly nested) MCP key.""" src = next(a for a in AGENTS if a.id == agent_id) agent = replace(src, mcp=replace(src.mcp, path=tmp_path / "cfg.json")) merge_mcp(agent) - assert "semble" in json.loads((tmp_path / "cfg.json").read_text())[key] + section = json.loads((tmp_path / "cfg.json").read_text()) + for part in key.split("."): + section = section[part] + assert "semble" in section def test_mcp_skipped_when_grammar_unavailable(claude_agent, monkeypatch): From 6e79167e2282f4477cd303d7a2aced952ec4a3ad Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 16:26:31 +0200 Subject: [PATCH 3/4] Cleanup --- src/semble/installer/agents.py | 2 -- src/semble/installer/config.py | 13 +++---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 80f518c85..a3670f018 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -208,8 +208,6 @@ def _vscode_mcp_path() -> Path: display_name="ZCode", binary=None, config_dir=_HOME / ".zcode", - # Confirmed by inspecting ZCode's app.asar (v3.2.4): servers live under mcp.servers. - # in ~/.zcode/cli/config.json (functions readServerMapFromJson/writeServerMapToJson). mcp=McpConfig(_HOME / ".zcode" / "cli" / "config.json", "mcp.servers", _STDIO_SERVER_CONFIG), instructions_path=_HOME / ".zcode" / "AGENTS.md", subagent_path=_HOME / ".zcode" / "agents" / "semble-search.md", diff --git a/src/semble/installer/config.py b/src/semble/installer/config.py index e4b8b1c62..3cb1cc8b8 100644 --- a/src/semble/installer/config.py +++ b/src/semble/installer/config.py @@ -104,7 +104,7 @@ def _reparse_ok(text: str) -> bool: def _nested_skeleton(keys: list[str], member_key: str, value: dict[str, object]) -> dict[str, object]: - """Build `{keys[0]: {keys[1]: ... {member_key: value}}}`.""" + """Wrap `value` under `member_key`, then under each of `keys` in turn.""" nested: dict[str, object] = {member_key: value} for key in reversed(keys): nested = {key: nested} @@ -132,11 +132,7 @@ def _resolve_or_create_section( def merge_json_member(path: Path, section_key: str, member_key: str, value: dict[str, object]) -> Action: - """Add or update `section_key.member_key = value` in a JSON5 config file, preserving comments and formatting. - - `section_key` may itself be a dot-separated path (e.g. "mcp.servers") to nest through multiple - container objects, all created on demand. - """ + """Add or update `section_key.member_key = value` in a JSON5 config file, preserving comments and formatting.""" existed = path.exists() text = path.read_text(encoding="utf-8") if existed else "" section_path = section_key.split(".") @@ -177,10 +173,7 @@ def merge_json_member(path: Path, section_key: str, member_key: str, value: dict def remove_json_member(path: Path, section_key: str, member_key: str) -> Action: - """Remove `section_key.member_key` from a JSON5 config file, leaving everything else intact. - - `section_key` may itself be a dot-separated path (e.g. "mcp.servers"), mirroring `merge_json_member`. - """ + """Remove `section_key.member_key` from a JSON5 config file, leaving everything else intact.""" if not path.exists(): return "not-found" From a32af4807b1fa6072e5f2410fe76412d22245b88 Mon Sep 17 00:00:00 2001 From: Pringled Date: Mon, 6 Jul 2026 08:36:24 +0200 Subject: [PATCH 4/4] Bump version --- src/semble/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semble/version.py b/src/semble/version.py index f531d7879..29d357cf4 100644 --- a/src/semble/version.py +++ b/src/semble/version.py @@ -1,2 +1,2 @@ -__version_triple__ = (0, 4, 1) +__version_triple__ = (0, 4, 2) __version__ = ".".join(map(str, __version_triple__))