diff --git a/README.md b/README.md index 8c5f9dd3..a84d3621 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 plus a one-line pointer to it in AGENTS.md / CLAUDE.md, instead of the full instructions. 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 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 cbb84435..10c219bc 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 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: @@ -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 but `skill`). 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,21 @@ 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. Copy [`src/semble/agents/skill.md`](../src/semble/agents/skill.md) to the destination below as `SKILL.md`: + +| Agent | Destination | +|---|---| +| Claude Code | `~/.claude/skills/semble/SKILL.md` | +| Qwen Code | `~/.qwen/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. + +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/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: 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/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/cli.py b/src/semble/cli.py index 60e7e714..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, 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/agents.py b/src/semble/installer/agents.py index e2c6968f..b8420f5b 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 = "" @@ -125,6 +126,8 @@ def semble_pin() -> str: {SEMBLE_END} """ +SKILL_POINTER = f"{SEMBLE_START}\nUse the `semble` skill for code search instead of Grep or Glob.\n{SEMBLE_END}\n" + @dataclass(frozen=True) class McpConfig: @@ -155,6 +158,11 @@ 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 + + +# 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: @@ -191,6 +199,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 +209,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=_SHARED_SKILL_PATH, ), AgentTarget( id="gemini", @@ -209,6 +219,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=_SHARED_SKILL_PATH, ), AgentTarget( id="kiro", @@ -227,6 +238,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=_SHARED_SKILL_PATH, ), AgentTarget( id="copilot", @@ -236,6 +248,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=_SHARED_SKILL_PATH, ), AgentTarget( id="codex", @@ -245,6 +258,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=_SHARED_SKILL_PATH, ), AgentTarget( id="zcode", @@ -299,6 +313,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=_SHARED_SKILL_PATH, ), AgentTarget( id="commandcode", @@ -335,6 +350,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 +372,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=_SHARED_SKILL_PATH, ), ] diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 33d5ee39..9f4089c9 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_POINTER, AgentTarget, IntegrationType, Mode, @@ -41,13 +43,14 @@ @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 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: @@ -83,11 +86,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 +96,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 +106,38 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: 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 + return _sync_file(dest, mode, lambda: _bundled(f"{agent.id}{dest.suffix}")) + + +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: _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 + + _INTEGRATIONS: list[_Integration] = [ _Integration( IntegrationType.MCP, @@ -115,6 +146,14 @@ def _apply_subagent(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", @@ -203,7 +242,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") @@ -213,7 +252,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 = [ @@ -225,7 +264,7 @@ 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] + 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 90f57952..309268cd 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -14,6 +14,7 @@ SEMBLE_END, SEMBLE_PIN, SEMBLE_START, + SKILL_POINTER, 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,33 @@ def test_apply_subagent(tmp_path): assert _apply_subagent(replace(agent, id="zzz"), "install").action == "error" +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(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) 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.""" @@ -662,6 +693,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.""" @@ -673,8 +708,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: @@ -683,6 +718,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(