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
26 changes: 24 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -298,6 +298,27 @@ cmd mcp add --scope user semble -- uvx --from "semble[mcp]" semble

</details>

<details>
<summary>ZCode</summary>

Add to `~/.zcode/cli/config.json` under the nested `mcp.servers` key (or use Settings -> MCP Servers -> Full configuration mode):

```json
{
"mcp": {
"servers": {
"semble": {
"command": "uvx",
"args": ["--from", "semble[mcp]", "semble"],
"type": "stdio"
}
}
}
}
```

</details>

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
Expand Down Expand Up @@ -350,7 +371,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.

Expand All @@ -367,3 +388,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` |
41 changes: 41 additions & 0 deletions src/semble/agents/zcode.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 9 additions & 0 deletions src/semble/installer/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" / "cli" / "config.json", "mcp.servers", _STDIO_SERVER_CONFIG),
instructions_path=_HOME / ".zcode" / "AGENTS.md",
subagent_path=_HOME / ".zcode" / "agents" / "semble-search.md",
),
AgentTarget(
id="vscode",
display_name="VS Code",
Expand Down
68 changes: 51 additions & 17 deletions src/semble/installer/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,34 +103,65 @@ 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]:
"""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}
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."""
existed = path.exists()
text = path.read_text(encoding="utf-8") if existed else ""
section_path = section_key.split(".")

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 Literal Dotted Keys Become Paths

When an existing JSON config contains a literal top-level key like "mcp.servers", this split makes merge_json_member look for mcp then servers instead of updating that member. The install then writes a second nested mcp.servers tree and leaves the original entry behind, so callers using literal dotted section names can get duplicate or stale MCP config.


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:
Expand All @@ -151,10 +182,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"

Expand Down
2 changes: 1 addition & 1 deletion src/semble/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version_triple__ = (0, 4, 1)
__version_triple__ = (0, 4, 2)
__version__ = ".".join(map(str, __version_triple__))
26 changes: 24 additions & 2 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
[
Expand All @@ -134,14 +152,18 @@ def test_merge_mcp_errors(claude_agent, content):
("pi", "mcpServers"),
("commandcode", "mcpServers"),
("antigravity", "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):
Expand Down
Loading