From 92fc3d934efcd91877985594115dee4d6f8ef5b8 Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 09:38:47 +0200 Subject: [PATCH 1/6] feat: Add skill integration to semble install --- README.md | 3 +- docs/installation.md | 21 +++++++++++-- src/semble/cli.py | 2 +- src/semble/installer/agents.py | 25 +++++++++++++-- src/semble/installer/installer.py | 51 +++++++++++++++++++++++++------ tests/test_installer.py | 19 ++++++++++++ 6 files changed, 105 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8c5f9dd3..aa6ef9ff 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ semble install - **MCP server**: lets the agent call Semble directly as a tool. - **Instructions**: adds CLI usage guidance to AGENTS.md / CLAUDE.md. - **Sub-agent**: installs a dedicated `semble-search` sub-agent. +- **Skill**: installs a `semble` skill the agent loads on demand, instead of growing AGENTS.md / CLAUDE.md. Off by default. To undo the setup, run `semble uninstall`. @@ -71,7 +72,7 @@ For sandboxed or scripted environments, skip the prompts with `--agent` and, opt semble install --agent claude --type mcp subagent --yes ``` -`--agent` accepts one or more agent ids (e.g. `claude`, `codex`, `pi`); `--type` accepts `mcp`, `instructions`, `subagent`, or `all` (default: all); `--yes` skips the confirmation prompt (requires `--agent` for a fully non-interactive run). +`--agent` accepts one or more agent ids (e.g. `claude`, `codex`, `pi`); `--type` accepts `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all); `--yes` skips the confirmation prompt (requires `--agent` for a fully non-interactive run). diff --git a/docs/installation.md b/docs/installation.md index cbb84435..b65a65c4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,11 +2,12 @@ ## Recommended: `semble install` -The interactive installer detects your installed agents and configures any combination of three integrations globally: +The interactive installer detects your installed agents and configures any combination of four integrations globally: - **[MCP server](#mcp-server)**: exposes Semble as a native tool your agent can call directly. - **[AGENTS.md](#instructions-agentsmd--claudemd)**: adds a Semble usage guide to the agent's config file (`CLAUDE.md`, `AGENTS.md`, etc.). - **[Sub-agent](#sub-agent)**: installs a dedicated `semble-search` sub-agent for harnesses that support it. +- **[Skill](#skill)**: installs a `semble` skill that the agent loads on demand, as an alternative to the AGENTS.md block. Off by default. Install the CLI with [uv](https://docs.astral.sh/uv/getting-started/installation/), then run: @@ -34,7 +35,7 @@ semble install --agent claude pi --type mcp subagent --yes ``` - `--agent` — one or more agent ids (see the list above; use the lowercase form, e.g. `claude`, `codex`, `pi`). -- `--type` — one or more of `mcp`, `instructions`, `subagent`, or `all` (default: all). Requires `--agent`. +- `--type` — one or more of `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all). Requires `--agent`. - `-y`/`--yes` — skip the confirmation prompt. Requires `--agent` for a fully non-interactive run. `semble uninstall` accepts the same flags. @@ -479,3 +480,19 @@ For harnesses that support sub-agents (Claude Code, Cursor, Gemini CLI, Kiro, Op | Grok Build | `grok.md` | `~/.grok/agents/semble-search.md` | | Qwen Code | `qwen.md` | `~/.qwen/agents/semble-search.md` | | Kilo Code | `kilo.md` | `~/.config/kilo/agents/semble-search.md` | + +### Skill + +For harnesses that support [Agent Skills](https://agentskills.io), you can install a `semble` skill instead of the AGENTS.md block. The agent only loads it when a task calls for code search. It has the same content as the [AGENTS.md](#instructions-agentsmd--claudemd) block, with `name` and `description` frontmatter. Save it as `SKILL.md` in a `semble` directory: + +| Agent | Destination | +|---|---| +| Claude Code | `~/.claude/skills/semble/SKILL.md` | +| Cursor | `~/.cursor/skills/semble/SKILL.md` | +| Gemini CLI | `~/.gemini/skills/semble/SKILL.md` | +| OpenCode | `~/.config/opencode/skills/semble/SKILL.md` | +| GitHub Copilot | `~/.copilot/skills/semble/SKILL.md` | +| Codex | `~/.agents/skills/semble/SKILL.md` | +| Pi | `~/.pi/agent/skills/semble/SKILL.md` | +| Qwen Code | `~/.qwen/skills/semble/SKILL.md` | +| Kilo Code | `~/.kilo/skills/semble/SKILL.md` | diff --git a/src/semble/cli.py b/src/semble/cli.py index 60e7e714..09cf0abb 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -332,7 +332,7 @@ def _cli_main() -> None: nargs="+", choices=[*(t.value for t in IntegrationType), "all"], metavar="TYPE", - help="Integrations to include (mcp, instructions, subagent, or all). Default: all. Requires --agent.", + help="Integrations to include (mcp, instructions, subagent, skill, or all). Default: all. Requires --agent.", ) p.add_argument( "-y", diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index e2c6968f..ee918a87 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -38,6 +38,7 @@ class IntegrationType(str, Enum): MCP = "mcp" INSTRUCTIONS = "instructions" SUBAGENT = "subagent" + SKILL = "skill" SEMBLE_START = "" @@ -90,8 +91,7 @@ def semble_pin() -> str: "args": ["--from", SEMBLE_PIN, "semble"], } -INSTRUCTIONS = f"""\ -{SEMBLE_START} +_INSTRUCTIONS_BODY = f"""\ ## Semble Code Search A `semble` MCP server is available with two tools: @@ -122,9 +122,18 @@ def semble_pin() -> str: 4. Set the MCP search tool's `content` field to `docs`, `config`, or `all` when searching beyond code. 5. Optionally use semble's `find_related` tool with `file_path`, `line`, and the same `content` selection to discover similar code elsewhere. 6. Use Grep only when you need every occurrence of a literal string across the whole repo (e.g., all callers of a renamed function). -{SEMBLE_END} """ +INSTRUCTIONS = f"{SEMBLE_START}\n{_INSTRUCTIONS_BODY}{SEMBLE_END}\n" + +SKILL = f"""\ +--- +name: semble +description: Search code with semble instead of Grep/Glob. Use for finding where something is implemented, locating code by intent or symbol name, searching docs or config, or finding code related to a known file and line. +--- + +{_INSTRUCTIONS_BODY}""" + @dataclass(frozen=True) class McpConfig: @@ -155,6 +164,7 @@ class AgentTarget: mcp: McpConfig | None instructions_path: Path | None # None = not supported for this agent subagent_path: Path | None = None # global (user-level) sub-agent file; None = unsupported + skill_path: Path | None = None # global (user-level) SKILL.md; None = unsupported def _xdg_config_dir(app: str) -> Path: @@ -191,6 +201,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".claude.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=_HOME / ".claude" / "CLAUDE.md", subagent_path=_HOME / ".claude" / "agents" / "semble-search.md", + skill_path=_HOME / ".claude" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="cursor", @@ -200,6 +211,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".cursor" / "mcp.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=None, # Cursor instructions are project-local .mdc files subagent_path=_HOME / ".cursor" / "agents" / "semble-search.md", + skill_path=_HOME / ".cursor" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="gemini", @@ -209,6 +221,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".gemini" / "settings.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=_HOME / ".gemini" / "GEMINI.md", subagent_path=_HOME / ".gemini" / "agents" / "semble-search.md", + skill_path=_HOME / ".gemini" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="kiro", @@ -227,6 +240,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_xdg_jsonc_path("opencode"), "mcp", _OPENCODE_SERVER_CONFIG), instructions_path=_xdg_config_dir("opencode") / "AGENTS.md", subagent_path=_xdg_config_dir("opencode") / "agents" / "semble-search.md", + skill_path=_xdg_config_dir("opencode") / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="copilot", @@ -236,6 +250,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".copilot" / "mcp-config.json", "mcpServers", _BARE_STDIO_SERVER_CONFIG), instructions_path=None, subagent_path=_HOME / ".copilot" / "agents" / "semble-search.agent.md", + skill_path=_HOME / ".copilot" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="codex", @@ -245,6 +260,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".codex" / "config.toml", "mcp_servers", _STDIO_SERVER_CONFIG, format="toml"), instructions_path=_HOME / ".codex" / "AGENTS.md", subagent_path=_HOME / ".codex" / "agents" / "semble-search.toml", + skill_path=_HOME / ".agents" / "skills" / "semble" / "SKILL.md", # Codex reads user skills from ~/.agents ), AgentTarget( id="zcode", @@ -299,6 +315,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".pi" / "agent" / "mcp.json", "mcpServers", _BARE_STDIO_SERVER_CONFIG), instructions_path=None, subagent_path=_HOME / ".pi" / "agents" / "semble-search.md", + skill_path=_HOME / ".pi" / "agent" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="commandcode", @@ -335,6 +352,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".qwen" / "settings.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=_HOME / ".qwen" / "QWEN.md", subagent_path=_HOME / ".qwen" / "agents" / "semble-search.md", + skill_path=_HOME / ".qwen" / "skills" / "semble" / "SKILL.md", ), AgentTarget( id="cline", @@ -356,6 +374,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_xdg_jsonc_path("kilo"), "mcp", _OPENCODE_SERVER_CONFIG), instructions_path=_xdg_config_dir("kilo") / "AGENTS.md", subagent_path=_xdg_config_dir("kilo") / "agents" / "semble-search.md", + skill_path=_HOME / ".kilo" / "skills" / "semble" / "SKILL.md", ), ] diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 33d5ee39..09b32a06 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from contextlib import suppress from dataclasses import dataclass from importlib.resources import files from pathlib import Path @@ -12,6 +13,7 @@ AGENTS, INSTRUCTIONS, SEMBLE_PIN, + SKILL, AgentTarget, IntegrationType, Mode, @@ -41,7 +43,7 @@ @dataclass(frozen=True) class _Integration: - """Descriptor for one installer integration (MCP server, instructions, sub-agent).""" + """Descriptor for one installer integration (MCP server, instructions, sub-agent, skill).""" id: IntegrationType label: str @@ -83,11 +85,8 @@ def _apply_instructions(agent: AgentTarget, mode: Mode) -> WriteResult | None: return WriteResult(path, action) -def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: - """Apply or remove the global sub-agent file for one agent.""" - dest = agent.subagent_path - if dest is None: - return None +def _sync_file(dest: Path, mode: Mode, render: Callable[[], str]) -> WriteResult: + """Write the rendered file to dest on install, or delete it on uninstall.""" if mode == "uninstall": if not dest.exists(): return WriteResult(dest, "not-found") @@ -96,9 +95,8 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: existed = dest.exists() dest.parent.mkdir(parents=True, exist_ok=True) try: - src = files("semble").joinpath(f"agents/{agent.id}{dest.suffix}").read_text(encoding="utf-8") # Bytes on both sides: no newline translation on Windows, and an undecodable file is just stale. - content = src.replace('"semble[mcp]"', f'"{SEMBLE_PIN}"').encode("utf-8") + content = render().encode("utf-8") if existed and dest.read_bytes() == content: return WriteResult(dest, "unchanged") dest.write_bytes(content) @@ -107,6 +105,31 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: return WriteResult(dest, "updated" if existed else "created") +def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: + """Apply or remove the global sub-agent file for one agent.""" + dest = agent.subagent_path + if dest is None: + return None + + def render() -> str: + src = files("semble").joinpath(f"agents/{agent.id}{dest.suffix}").read_text(encoding="utf-8") + return src.replace('"semble[mcp]"', f'"{SEMBLE_PIN}"') + + return _sync_file(dest, mode, render) + + +def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: + """Apply or remove the global semble skill for one agent.""" + dest = agent.skill_path + if dest is None: + return None + result = _sync_file(dest, mode, lambda: SKILL) + if result.action == "removed": + with suppress(OSError): # leave the skill directory if anything else is in it + dest.parent.rmdir() + return result + + _INTEGRATIONS: list[_Integration] = [ _Integration( IntegrationType.MCP, @@ -129,6 +152,13 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: _apply_subagent, lambda a: a.subagent_path, ), + _Integration( + IntegrationType.SKILL, + "Skill", + "installs a semble skill the agent loads on demand", + _apply_skill, + lambda a: a.skill_path, + ), ] @@ -225,7 +255,10 @@ def run( ) or _exit("Nothing selected. Exiting.") max_label = max(len(i.label) for i in _INTEGRATIONS) - integ_items = [(f"{i.label:<{max_label}} — {i.desc}", i, True) for i in _INTEGRATIONS] + # The skill overlaps with the instructions block, so it is opt-in. + integ_items = [ + (f"{i.label:<{max_label}} — {i.desc}", i, i.id != IntegrationType.SKILL) for i in _INTEGRATIONS + ] chosen_integrations = _checkbox( f"Select integrations to {'enable' if install else 'remove'}:", integ_items ) or _exit("Nothing selected. Exiting.") diff --git a/tests/test_installer.py b/tests/test_installer.py index 90f57952..6b15ce06 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -14,6 +14,7 @@ SEMBLE_END, SEMBLE_PIN, SEMBLE_START, + SKILL, IntegrationType, _vscode_mcp_path, _xdg_jsonc_path, @@ -35,6 +36,7 @@ _INTEGRATIONS, _apply_instructions, _apply_mcp, + _apply_skill, _apply_subagent, _checkbox, _print_plan, @@ -57,6 +59,7 @@ def claude_agent(tmp_path): mcp=replace(a.mcp, path=tmp_path / ".claude.json"), instructions_path=tmp_path / ".claude" / "CLAUDE.md", subagent_path=tmp_path / ".claude" / "agents" / "semble-search.md", + skill_path=tmp_path / ".claude" / "skills" / "semble" / "SKILL.md", ) @@ -68,6 +71,7 @@ def run_setup(monkeypatch, tmp_path, claude_agent): cursor, mcp=replace(cursor.mcp, path=tmp_path / "cursor.json"), subagent_path=tmp_path / "cursor_subagent.md", + skill_path=tmp_path / "cursor_skill" / "SKILL.md", ) monkeypatch.setattr("semble.installer.installer.AGENTS", [claude_agent, cursor]) monkeypatch.setattr("semble.installer.installer._checkbox", lambda _p, items: [v for _, v, _ in items]) @@ -398,6 +402,21 @@ def test_apply_subagent(tmp_path): assert _apply_subagent(replace(agent, id="zzz"), "install").action == "error" +def test_apply_skill(tmp_path): + """_apply_skill writes SKILL.md with the pinned instructions and removes its directory on uninstall.""" + dest = tmp_path / "skills" / "semble" / "SKILL.md" + agent = replace(next(a for a in AGENTS if a.id == "claude"), skill_path=dest) + + assert _apply_skill(agent, "install").action == "created" + assert dest.read_text() == SKILL + assert SKILL.startswith("---\nname: semble\n") and SEMBLE_PIN in SKILL and SEMBLE_START not in SKILL + assert _apply_skill(agent, "install").action == "unchanged" + assert _apply_skill(agent, "uninstall").action == "removed" + assert not dest.parent.exists() + assert _apply_skill(agent, "uninstall").action == "not-found" + assert _apply_skill(replace(agent, skill_path=None), "install") is None + + @pytest.mark.parametrize("agent", [a for a in AGENTS if a.subagent_path is not None], ids=lambda a: a.id) def test_apply_subagent_bundled_file_for_every_agent(tmp_path, agent): """Every agent with a sub-agent path ships a bundled file that installs with the pinned uvx spec.""" From 00709f14e6a63421a3159701f989faadbcd58a1e Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 09:41:53 +0200 Subject: [PATCH 2/6] feat: Install skill to shared ~/.agents/skills, deny grep/glob in OpenCode sub-agent --- docs/installation.md | 10 +++------- src/semble/agents/opencode.md | 2 ++ src/semble/installer/agents.py | 18 +++++++++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index b65a65c4..1ad08f5b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -488,11 +488,7 @@ For harnesses that support [Agent Skills](https://agentskills.io), you can insta | Agent | Destination | |---|---| | Claude Code | `~/.claude/skills/semble/SKILL.md` | -| Cursor | `~/.cursor/skills/semble/SKILL.md` | -| Gemini CLI | `~/.gemini/skills/semble/SKILL.md` | -| OpenCode | `~/.config/opencode/skills/semble/SKILL.md` | -| GitHub Copilot | `~/.copilot/skills/semble/SKILL.md` | -| Codex | `~/.agents/skills/semble/SKILL.md` | -| Pi | `~/.pi/agent/skills/semble/SKILL.md` | | Qwen Code | `~/.qwen/skills/semble/SKILL.md` | -| Kilo Code | `~/.kilo/skills/semble/SKILL.md` | +| Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, Pi, Kilo Code | `~/.agents/skills/semble/SKILL.md` | + +`~/.agents/skills` is shared between agents, so uninstalling the skill for one of them removes it for all of them. diff --git a/src/semble/agents/opencode.md b/src/semble/agents/opencode.md index aa962db5..2c5456d2 100644 --- a/src/semble/agents/opencode.md +++ b/src/semble/agents/opencode.md @@ -5,6 +5,8 @@ mode: subagent permission: bash: allow read: allow + glob: deny + grep: deny --- Use `semble search` to find code by describing what it does or naming a symbol/identifier, instead of grep: diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index ee918a87..9407834a 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -167,6 +167,10 @@ class AgentTarget: skill_path: Path | None = None # global (user-level) SKILL.md; None = unsupported +# Cross-agent skills directory, read by Codex, Cursor, Gemini CLI, Copilot, OpenCode, Pi and Kilo. +_SHARED_SKILL_PATH = _HOME / ".agents" / "skills" / "semble" / "SKILL.md" + + def _xdg_config_dir(app: str) -> Path: """Return an XDG app's config directory (e.g. opencode, kilo), honoring XDG_CONFIG_HOME.""" xdg = os.environ.get("XDG_CONFIG_HOME") @@ -211,7 +215,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".cursor" / "mcp.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=None, # Cursor instructions are project-local .mdc files subagent_path=_HOME / ".cursor" / "agents" / "semble-search.md", - skill_path=_HOME / ".cursor" / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="gemini", @@ -221,7 +225,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".gemini" / "settings.json", "mcpServers", _STDIO_SERVER_CONFIG), instructions_path=_HOME / ".gemini" / "GEMINI.md", subagent_path=_HOME / ".gemini" / "agents" / "semble-search.md", - skill_path=_HOME / ".gemini" / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="kiro", @@ -240,7 +244,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_xdg_jsonc_path("opencode"), "mcp", _OPENCODE_SERVER_CONFIG), instructions_path=_xdg_config_dir("opencode") / "AGENTS.md", subagent_path=_xdg_config_dir("opencode") / "agents" / "semble-search.md", - skill_path=_xdg_config_dir("opencode") / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="copilot", @@ -250,7 +254,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".copilot" / "mcp-config.json", "mcpServers", _BARE_STDIO_SERVER_CONFIG), instructions_path=None, subagent_path=_HOME / ".copilot" / "agents" / "semble-search.agent.md", - skill_path=_HOME / ".copilot" / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="codex", @@ -260,7 +264,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".codex" / "config.toml", "mcp_servers", _STDIO_SERVER_CONFIG, format="toml"), instructions_path=_HOME / ".codex" / "AGENTS.md", subagent_path=_HOME / ".codex" / "agents" / "semble-search.toml", - skill_path=_HOME / ".agents" / "skills" / "semble" / "SKILL.md", # Codex reads user skills from ~/.agents + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="zcode", @@ -315,7 +319,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_HOME / ".pi" / "agent" / "mcp.json", "mcpServers", _BARE_STDIO_SERVER_CONFIG), instructions_path=None, subagent_path=_HOME / ".pi" / "agents" / "semble-search.md", - skill_path=_HOME / ".pi" / "agent" / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), AgentTarget( id="commandcode", @@ -374,7 +378,7 @@ def _vscode_mcp_path() -> Path: mcp=McpConfig(_xdg_jsonc_path("kilo"), "mcp", _OPENCODE_SERVER_CONFIG), instructions_path=_xdg_config_dir("kilo") / "AGENTS.md", subagent_path=_xdg_config_dir("kilo") / "agents" / "semble-search.md", - skill_path=_HOME / ".kilo" / "skills" / "semble" / "SKILL.md", + skill_path=_SHARED_SKILL_PATH, ), ] From 2a98c24ea924dc8c333d9cd3701ae5de154d0d4e Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 09:46:35 +0200 Subject: [PATCH 3/6] feat: Deny grep/glob in Kilo sub-agent --- src/semble/agents/kilo.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/semble/agents/kilo.md b/src/semble/agents/kilo.md index aa962db5..2c5456d2 100644 --- a/src/semble/agents/kilo.md +++ b/src/semble/agents/kilo.md @@ -5,6 +5,8 @@ mode: subagent permission: bash: allow read: allow + glob: deny + grep: deny --- Use `semble search` to find code by describing what it does or naming a symbol/identifier, instead of grep: From b6482b244cb10b692d2cae91dee8aeef9c3167c3 Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 09:50:31 +0200 Subject: [PATCH 4/6] feat: Match unattended install defaults to the interactive picker --- README.md | 2 +- docs/installation.md | 2 +- src/semble/cli.py | 10 ++++++++-- src/semble/installer/installer.py | 11 +++++------ tests/test_installer.py | 10 ++++++++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aa6ef9ff..db864576 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ For sandboxed or scripted environments, skip the prompts with `--agent` and, opt semble install --agent claude --type mcp subagent --yes ``` -`--agent` accepts one or more agent ids (e.g. `claude`, `codex`, `pi`); `--type` accepts `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all); `--yes` skips the confirmation prompt (requires `--agent` for a fully non-interactive run). +`--agent` accepts one or more agent ids (e.g. `claude`, `codex`, `pi`); `--type` accepts `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all but `skill`); `--yes` skips the confirmation prompt (requires `--agent` for a fully non-interactive run). diff --git a/docs/installation.md b/docs/installation.md index 1ad08f5b..c29476f3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -35,7 +35,7 @@ semble install --agent claude pi --type mcp subagent --yes ``` - `--agent` — one or more agent ids (see the list above; use the lowercase form, e.g. `claude`, `codex`, `pi`). -- `--type` — one or more of `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all). Requires `--agent`. +- `--type` — one or more of `mcp`, `instructions`, `subagent`, `skill`, or `all` (default: all but `skill`). Requires `--agent`. - `-y`/`--yes` — skip the confirmation prompt. Requires `--agent` for a fully non-interactive run. `semble uninstall` accepts the same flags. diff --git a/src/semble/cli.py b/src/semble/cli.py index 09cf0abb..e9214660 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -332,7 +332,7 @@ def _cli_main() -> None: nargs="+", choices=[*(t.value for t in IntegrationType), "all"], metavar="TYPE", - help="Integrations to include (mcp, instructions, subagent, skill, or all). Default: all. Requires --agent.", + help="Integrations to include (mcp, instructions, subagent, skill, or all). Default: all but skill. Requires --agent.", ) p.add_argument( "-y", @@ -351,7 +351,13 @@ def _cli_main() -> None: from semble.installer import run - integration_ids = None if not args.type or "all" in args.type else [IntegrationType(t) for t in args.type] + integration_ids = ( + None + if not args.type + else list(IntegrationType) + if "all" in args.type + else [IntegrationType(t) for t in args.type] + ) run(args.command, agent_ids=args.agent, integration_ids=integration_ids, yes=args.yes) elif args.command == "clear": _run_clear(args.type) diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 09b32a06..30960b46 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -50,6 +50,7 @@ class _Integration: desc: str apply: Callable[[AgentTarget, Mode], WriteResult | None] plan_path: Callable[[AgentTarget], Path | None] + default: bool = True # selected when the user doesn't choose integrations def merge_mcp(agent: AgentTarget) -> WriteResult: @@ -158,6 +159,7 @@ def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: "installs a semble skill the agent loads on demand", _apply_skill, lambda a: a.skill_path, + default=False, # overlaps with the instructions block ), ] @@ -233,7 +235,7 @@ def run( """Install or uninstall semble across coding agents. Prompts interactively unless `agent_ids` is given, in which case it runs unattended - against those agents (and `integration_ids`, or all integrations if omitted). + against those agents (and `integration_ids`, or the default integrations if omitted). """ install = mode == "install" print(f"\n {_BOLD}{'Semble Installer' if install else 'Semble Uninstaller'}{_RESET}\n") @@ -243,7 +245,7 @@ def run( chosen_integrations = ( [i for i in _INTEGRATIONS if i.id in integration_ids] if integration_ids is not None - else list(_INTEGRATIONS) + else [i for i in _INTEGRATIONS if i.default] ) or _exit("No matching integrations. Exiting.", code=1) else: agent_items = [ @@ -255,10 +257,7 @@ def run( ) or _exit("Nothing selected. Exiting.") max_label = max(len(i.label) for i in _INTEGRATIONS) - # The skill overlaps with the instructions block, so it is opt-in. - integ_items = [ - (f"{i.label:<{max_label}} — {i.desc}", i, i.id != IntegrationType.SKILL) for i in _INTEGRATIONS - ] + integ_items = [(f"{i.label:<{max_label}} — {i.desc}", i, i.default) for i in _INTEGRATIONS] chosen_integrations = _checkbox( f"Select integrations to {'enable' if install else 'remove'}:", integ_items ) or _exit("Nothing selected. Exiting.") diff --git a/tests/test_installer.py b/tests/test_installer.py index 6b15ce06..28f3982e 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -681,6 +681,10 @@ def test_cli_unattended_flags(monkeypatch, command): assert calls == [(command, {"agent_ids": ["pi"], "integration_ids": [IntegrationType.SUBAGENT], "yes": True})] assert isinstance(calls[0][1]["integration_ids"][0], IntegrationType) + monkeypatch.setattr(sys, "argv", ["semble", command, "--agent", "pi", "--type", "all"]) + cli.main() + assert calls[-1][1]["integration_ids"] == list(IntegrationType) + def test_cli_type_without_agent_errors(monkeypatch, capsys): """--type without --agent is rejected with a usage error.""" @@ -692,8 +696,8 @@ def test_cli_type_without_agent_errors(monkeypatch, capsys): assert "--type requires --agent" in capsys.readouterr().err -def test_run_unattended_skips_prompts(run_setup, monkeypatch): - """run() with agent_ids skips both checkboxes and the confirmation prompt.""" +def test_run_unattended_skips_prompts(run_setup, monkeypatch, claude_agent): + """run() with agent_ids skips the prompts and installs only the default integrations.""" from semble.installer import run def _boom(*_: object, **__: object) -> None: @@ -702,6 +706,8 @@ def _boom(*_: object, **__: object) -> None: monkeypatch.setattr("semble.installer.installer._checkbox", _boom) monkeypatch.setattr("semble.installer.installer.questionary.confirm", _boom) run("install", agent_ids=["claude"], yes=True) + assert claude_agent.instructions_path.exists() + assert not claude_agent.skill_path.exists() @pytest.mark.parametrize( From 160f7059b2f53abef4fe1a8551a4b4afb8f3d1af Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 09:59:48 +0200 Subject: [PATCH 5/6] feat: Point to the semble skill from AGENTS.md / CLAUDE.md --- README.md | 2 +- docs/installation.md | 10 +++++-- src/semble/agents/skill.md | 45 +++++++++++++++++++++++++++++++ src/semble/installer/agents.py | 14 +++------- src/semble/installer/installer.py | 39 ++++++++++++++++----------- tests/test_installer.py | 38 +++++++++++++++++--------- 6 files changed, 106 insertions(+), 42 deletions(-) create mode 100644 src/semble/agents/skill.md diff --git a/README.md b/README.md index db864576..77f47b1d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ semble install - **MCP server**: lets the agent call Semble directly as a tool. - **Instructions**: adds CLI usage guidance to AGENTS.md / CLAUDE.md. - **Sub-agent**: installs a dedicated `semble-search` sub-agent. -- **Skill**: installs a `semble` skill the agent loads on demand, instead of growing AGENTS.md / CLAUDE.md. Off by default. +- **Skill**: installs a `semble` skill plus a one-line pointer to it in AGENTS.md / CLAUDE.md, instead of the full instructions. Off by default. To undo the setup, run `semble uninstall`. diff --git a/docs/installation.md b/docs/installation.md index c29476f3..10c219bc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,7 +7,7 @@ The interactive installer detects your installed agents and configures any combi - **[MCP server](#mcp-server)**: exposes Semble as a native tool your agent can call directly. - **[AGENTS.md](#instructions-agentsmd--claudemd)**: adds a Semble usage guide to the agent's config file (`CLAUDE.md`, `AGENTS.md`, etc.). - **[Sub-agent](#sub-agent)**: installs a dedicated `semble-search` sub-agent for harnesses that support it. -- **[Skill](#skill)**: installs a `semble` skill that the agent loads on demand, as an alternative to the AGENTS.md block. Off by default. +- **[Skill](#skill)**: installs a `semble` skill plus a one-line pointer to it in AGENTS.md / CLAUDE.md, as an alternative to the full block. Off by default. Install the CLI with [uv](https://docs.astral.sh/uv/getting-started/installation/), then run: @@ -483,7 +483,7 @@ For harnesses that support sub-agents (Claude Code, Cursor, Gemini CLI, Kiro, Op ### Skill -For harnesses that support [Agent Skills](https://agentskills.io), you can install a `semble` skill instead of the AGENTS.md block. The agent only loads it when a task calls for code search. It has the same content as the [AGENTS.md](#instructions-agentsmd--claudemd) block, with `name` and `description` frontmatter. Save it as `SKILL.md` in a `semble` directory: +For harnesses that support [Agent Skills](https://agentskills.io), you can install a `semble` skill instead of the AGENTS.md block. Copy [`src/semble/agents/skill.md`](../src/semble/agents/skill.md) to the destination below as `SKILL.md`: | Agent | Destination | |---|---| @@ -492,3 +492,9 @@ For harnesses that support [Agent Skills](https://agentskills.io), you can insta | Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, Pi, Kilo Code | `~/.agents/skills/semble/SKILL.md` | `~/.agents/skills` is shared between agents, so uninstalling the skill for one of them removes it for all of them. + +Agents rarely load a skill on their own, so also add this line to your AGENTS.md / CLAUDE.md (`semble install` does this for you): + +```markdown +Use the `semble` skill for code search instead of Grep or Glob. +``` diff --git a/src/semble/agents/skill.md b/src/semble/agents/skill.md new file mode 100644 index 00000000..eb8e22bb --- /dev/null +++ b/src/semble/agents/skill.md @@ -0,0 +1,45 @@ +--- +name: semble +description: Code search for the current repository. Use this before Grep, Glob or find whenever you need to locate code: where something is implemented, which file defines a function, class or symbol, how a feature works, or where docs and config live. +--- + +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. Pass several paths or URLs to search related repos together, e.g. when this project calls a service defined in a sibling repo. Result paths are then prefixed with the repo name and the output includes a `repos` map from prefix to location: + +```bash +semble search "invoice endpoint" ./service-a ../service-b +``` + +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. If the answer may live in a dependent or sibling repo, pass all relevant repo paths to one `semble search` call. +6. 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 9407834a..b8420f5b 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -91,7 +91,8 @@ def semble_pin() -> str: "args": ["--from", SEMBLE_PIN, "semble"], } -_INSTRUCTIONS_BODY = f"""\ +INSTRUCTIONS = f"""\ +{SEMBLE_START} ## Semble Code Search A `semble` MCP server is available with two tools: @@ -122,17 +123,10 @@ def semble_pin() -> str: 4. Set the MCP search tool's `content` field to `docs`, `config`, or `all` when searching beyond code. 5. Optionally use semble's `find_related` tool with `file_path`, `line`, and the same `content` selection to discover similar code elsewhere. 6. Use Grep only when you need every occurrence of a literal string across the whole repo (e.g., all callers of a renamed function). +{SEMBLE_END} """ -INSTRUCTIONS = f"{SEMBLE_START}\n{_INSTRUCTIONS_BODY}{SEMBLE_END}\n" - -SKILL = f"""\ ---- -name: semble -description: Search code with semble instead of Grep/Glob. Use for finding where something is implemented, locating code by intent or symbol name, searching docs or config, or finding code related to a known file and line. ---- - -{_INSTRUCTIONS_BODY}""" +SKILL_POINTER = f"{SEMBLE_START}\nUse the `semble` skill for code search instead of Grep or Glob.\n{SEMBLE_END}\n" @dataclass(frozen=True) diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 30960b46..9f4089c9 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -13,7 +13,7 @@ AGENTS, INSTRUCTIONS, SEMBLE_PIN, - SKILL, + SKILL_POINTER, AgentTarget, IntegrationType, Mode, @@ -106,17 +106,18 @@ def _sync_file(dest: Path, mode: Mode, render: Callable[[], str]) -> WriteResult return WriteResult(dest, "updated" if existed else "created") +def _bundled(name: str) -> str: + """Read a bundled agent file, pinning its uvx fallback to the installed semble version.""" + src = files("semble").joinpath(f"agents/{name}").read_text(encoding="utf-8") + return src.replace('"semble[mcp]"', f'"{SEMBLE_PIN}"') + + def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: """Apply or remove the global sub-agent file for one agent.""" dest = agent.subagent_path if dest is None: return None - - def render() -> str: - src = files("semble").joinpath(f"agents/{agent.id}{dest.suffix}").read_text(encoding="utf-8") - return src.replace('"semble[mcp]"', f'"{SEMBLE_PIN}"') - - return _sync_file(dest, mode, render) + return _sync_file(dest, mode, lambda: _bundled(f"{agent.id}{dest.suffix}")) def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: @@ -124,10 +125,16 @@ def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: dest = agent.skill_path if dest is None: return None - result = _sync_file(dest, mode, lambda: SKILL) + result = _sync_file(dest, mode, lambda: _bundled("skill.md")) if result.action == "removed": with suppress(OSError): # leave the skill directory if anything else is in it dest.parent.rmdir() + # Agents rarely load a skill unprompted, so point to it from the instructions file. + pointer = agent.instructions_path + if pointer is not None and mode == "install": + replace_or_append_marked(pointer, SKILL_POINTER) + elif pointer is not None and pointer.exists() and SKILL_POINTER in pointer.read_text(encoding="utf-8"): + remove_marked(pointer) # leave a full instructions block alone return result @@ -139,6 +146,14 @@ def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: _apply_mcp, lambda a: a.mcp.path if a.mcp else None, ), + _Integration( + IntegrationType.SKILL, + "Skill", + "installs a semble skill, with a one-line pointer in AGENTS.md / CLAUDE.md", + _apply_skill, + lambda a: a.skill_path, + default=False, # alternative to the instructions block; listed first so a chosen full block wins + ), _Integration( IntegrationType.INSTRUCTIONS, "Instructions", @@ -153,14 +168,6 @@ def _apply_skill(agent: AgentTarget, mode: Mode) -> WriteResult | None: _apply_subagent, lambda a: a.subagent_path, ), - _Integration( - IntegrationType.SKILL, - "Skill", - "installs a semble skill the agent loads on demand", - _apply_skill, - lambda a: a.skill_path, - default=False, # overlaps with the instructions block - ), ] diff --git a/tests/test_installer.py b/tests/test_installer.py index 28f3982e..309268cd 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -14,7 +14,7 @@ SEMBLE_END, SEMBLE_PIN, SEMBLE_START, - SKILL, + SKILL_POINTER, IntegrationType, _vscode_mcp_path, _xdg_jsonc_path, @@ -402,19 +402,31 @@ def test_apply_subagent(tmp_path): assert _apply_subagent(replace(agent, id="zzz"), "install").action == "error" -def test_apply_skill(tmp_path): - """_apply_skill writes SKILL.md with the pinned instructions and removes its directory on uninstall.""" - dest = tmp_path / "skills" / "semble" / "SKILL.md" - agent = replace(next(a for a in AGENTS if a.id == "claude"), skill_path=dest) +def test_apply_skill(claude_agent): + """_apply_skill installs the pinned SKILL.md plus a pointer, and removes only its own pointer on uninstall.""" + dest, md = claude_agent.skill_path, claude_agent.instructions_path - assert _apply_skill(agent, "install").action == "created" - assert dest.read_text() == SKILL - assert SKILL.startswith("---\nname: semble\n") and SEMBLE_PIN in SKILL and SEMBLE_START not in SKILL - assert _apply_skill(agent, "install").action == "unchanged" - assert _apply_skill(agent, "uninstall").action == "removed" - assert not dest.parent.exists() - assert _apply_skill(agent, "uninstall").action == "not-found" - assert _apply_skill(replace(agent, skill_path=None), "install") is None + assert _apply_skill(claude_agent, "install").action == "created" + text = dest.read_bytes().decode("utf-8") + assert text.startswith("---\nname: semble\n") and SEMBLE_PIN in text and '"semble[mcp]"' not in text + assert md.read_text(encoding="utf-8") == SKILL_POINTER + assert _apply_skill(claude_agent, "install").action == "unchanged" + assert _apply_skill(claude_agent, "uninstall").action == "removed" + assert not dest.parent.exists() and not md.exists() + assert _apply_skill(claude_agent, "uninstall").action == "not-found" + assert _apply_skill(replace(claude_agent, skill_path=None), "install") is None + + _apply_instructions(claude_agent, "install") + _apply_skill(claude_agent, "uninstall") + assert md.read_text(encoding="utf-8") == INSTRUCTIONS + + +def test_run_skill_and_instructions_keeps_full_block(run_setup, claude_agent): + """Choosing both skill and instructions leaves the full instructions block, not the pointer.""" + both = [IntegrationType.INSTRUCTIONS, IntegrationType.SKILL] + run("install", agent_ids=["claude"], integration_ids=both, yes=True) + assert claude_agent.skill_path.exists() + assert claude_agent.instructions_path.read_text(encoding="utf-8") == INSTRUCTIONS @pytest.mark.parametrize("agent", [a for a in AGENTS if a.subagent_path is not None], ids=lambda a: a.id) From 5b5f8a8a499d33eff2eabc484c7136f626168c3c Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 24 Oct 2026 10:02:32 +0200 Subject: [PATCH 6/6] docs: Drop default note from README skill bullet --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 77f47b1d..a84d3621 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ semble install - **MCP server**: lets the agent call Semble directly as a tool. - **Instructions**: adds CLI usage guidance to AGENTS.md / CLAUDE.md. - **Sub-agent**: installs a dedicated `semble-search` sub-agent. -- **Skill**: installs a `semble` skill plus a one-line pointer to it in AGENTS.md / CLAUDE.md, instead of the full instructions. Off by default. +- **Skill**: installs a `semble` skill plus a one-line pointer to it in AGENTS.md / CLAUDE.md, instead of the full instructions. To undo the setup, run `semble uninstall`.