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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ uv cache clean semble # for MCP users (restart your MCP client after)

</details>

<details>
<summary>Unattended install</summary>

For sandboxed or scripted environments, skip the prompts with `--agent` and, optionally, `--type`:

```bash
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).

</details>

## Main Features

- **Fast**: indexes an average repo in ~250 ms and answers queries in ~1.5 ms, all on CPU.
Expand Down
14 changes: 14 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ Supported agents: Claude Code, Cursor, Gemini CLI, Kiro, OpenCode, GitHub Copilo

> **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`.

### Unattended install

For sandboxed or scripted environments, pass `--agent` to skip the interactive prompts:

```bash
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`.
- `-y`/`--yes` — skip the confirmation prompt. Requires `--agent` for a fully non-interactive run.

`semble uninstall` accepts the same flags.

---

## Manual setup
Expand Down
32 changes: 29 additions & 3 deletions src/semble/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from semble.cache import find_index_from_cache_folder, resolve_cache_folder
from semble.index import SembleIndex
from semble.index.types import PersistencePath
from semble.installer.agents import AGENTS, IntegrationType
from semble.stats import format_savings_report
from semble.types import ContentType
from semble.utils import format_results, is_git_url, resolve_chunk
Expand Down Expand Up @@ -199,17 +200,42 @@ def _cli_main() -> None:

sub.add_parser("savings", help="Show token savings and usage stats.")

sub.add_parser("install", help="Interactively configure semble across coding agents.")
sub.add_parser("uninstall", help="Interactively remove semble configuration from coding agents.")
install_p = sub.add_parser("install", help="Configure semble across coding agents.")
uninstall_p = sub.add_parser("uninstall", help="Remove semble configuration from coding agents.")
for p, verb in ((install_p, "configure"), (uninstall_p, "remove configuration from")):
p.add_argument(
"--agent",
nargs="+",
choices=[a.id for a in AGENTS],
metavar="AGENT",
help=f"Agent(s) to {verb} non-interactively, e.g. --agent claude pi. Skips prompts.",
)
p.add_argument(
"--type",
nargs="+",
choices=[*(t.value for t in IntegrationType), "all"],
metavar="TYPE",
help="Integrations to include (mcp, instructions, subagent, or all). Default: all. Requires --agent.",
)
p.add_argument(
"-y",
"--yes",
action="store_true",
help="Skip the confirmation prompt. Combine with --agent for a fully non-interactive run.",
)

args = parser.parse_args()

if args.command == "savings":
print(format_savings_report())
elif args.command in ("install", "uninstall"):
if args.type and not args.agent:
parser.error("--type requires --agent")

from semble.installer import run

run(args.command)
integration_ids = None if not args.type or "all" in args.type else [IntegrationType(t) for t in args.type]
Comment on lines +232 to +237

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 Mixed All Ignores Specific Types

When --type all subagent or another mixed all invocation is passed, this branch turns the selection into None, so the installer applies every integration and silently ignores the extra specific values. Since --type accepts multiple values, this can install or remove a wider scope than the caller requested.

Suggested change
if args.type and not args.agent:
parser.error("--type requires --agent")
from semble.installer import run
run(args.command)
integration_ids = None if not args.type or "all" in args.type else [IntegrationType(t) for t in args.type]
if args.type and not args.agent:
parser.error("--type requires --agent")
if args.type and "all" in args.type and len(args.type) > 1:
parser.error("--type all cannot be combined with other types")
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]

run(args.command, agent_ids=args.agent, integration_ids=integration_ids, yes=args.yes)
elif args.command == "clear":
_run_clear(args.type)
elif args.command == "search":
Expand Down
10 changes: 10 additions & 0 deletions src/semble/installer/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal

Expand All @@ -12,6 +13,15 @@
Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"]
Mode = Literal["install", "uninstall"]


class IntegrationType(str, Enum):
"""Identifier for one of semble's install/uninstall integrations."""

MCP = "mcp"
INSTRUCTIONS = "instructions"
SUBAGENT = "subagent"


SEMBLE_START = "<!-- SEMBLE_START -->"
SEMBLE_END = "<!-- SEMBLE_END -->"

Expand Down
71 changes: 47 additions & 24 deletions src/semble/installer/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AGENTS,
INSTRUCTIONS,
AgentTarget,
IntegrationType,
Mode,
WriteResult,
is_detected,
Expand Down Expand Up @@ -41,7 +42,7 @@
class _Integration:
"""Descriptor for one installer integration (MCP server, instructions, sub-agent)."""

id: str
id: IntegrationType
label: str
desc: str
apply: Callable[[AgentTarget, Mode], WriteResult | None]
Expand Down Expand Up @@ -103,17 +104,21 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None:

_INTEGRATIONS: list[_Integration] = [
_Integration(
"mcp", "MCP server", "lets the agent call semble directly as a tool", _apply_mcp, AgentTarget.resolved_mcp_path
IntegrationType.MCP,
"MCP server",
"lets the agent call semble directly as a tool",
_apply_mcp,
AgentTarget.resolved_mcp_path,
),
_Integration(
"instructions",
IntegrationType.INSTRUCTIONS,
"Instructions",
"adds CLI usage guidance to AGENTS.md / CLAUDE.md",
_apply_instructions,
lambda a: a.instructions_path,
),
_Integration(
"subagent",
IntegrationType.SUBAGENT,
"Sub-agent",
"installs a dedicated semble-search sub-agent",
_apply_subagent,
Expand All @@ -127,10 +132,10 @@ def _tick(ok: bool) -> str:
return f"{_GREEN}✓{_RESET}" if ok else f"{_DIM}–{_RESET}"


def _exit(message: str) -> NoReturn:
"""Print message and exit with code 0."""
def _exit(message: str, code: int = 0) -> NoReturn:
"""Print message and exit with the given code (0 by default, for user-cancelled runs)."""
print(message)
sys.exit(0)
sys.exit(code)


def _checkbox(prompt: str, items: Sequence[tuple[str, _T, bool]]) -> list[_T] | None:
Expand Down Expand Up @@ -178,30 +183,48 @@ def _apply(mode: Mode, agents: list[AgentTarget], integrations: list[_Integratio
print()


def run(mode: Mode) -> None:
"""Interactively install or uninstall semble across coding agents."""
def run(
mode: Mode,
agent_ids: list[str] | None = None,
integration_ids: list[IntegrationType] | None = None,
yes: bool = False,
) -> None:
"""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).
"""
install = mode == "install"
print(f"\n {_BOLD}{'Semble Installer' if install else 'Semble Uninstaller'}{_RESET}\n")

agent_items = [
(f"{a.display_name}{' (detected)' if (detected := is_detected(a)) else ''}", a, detected and install)
for a in sorted(AGENTS, key=lambda a: not is_detected(a))
]
chosen_agents = _checkbox(
f"Select agents to {'configure' if install else 'remove configuration from'}:", agent_items
) or _exit("Nothing selected. Exiting.")
if agent_ids is not None:
chosen_agents = [a for a in AGENTS if a.id in agent_ids] or _exit("No matching agents. Exiting.", code=1)
chosen_integrations = (
[i for i in _INTEGRATIONS if i.id in integration_ids]
if integration_ids is not None
else list(_INTEGRATIONS)
) or _exit("No matching integrations. Exiting.", code=1)
else:
agent_items = [
(f"{a.display_name}{' (detected)' if (detected := is_detected(a)) else ''}", a, detected and install)
for a in sorted(AGENTS, key=lambda a: not is_detected(a))
]
chosen_agents = _checkbox(
f"Select agents to {'configure' if install else 'remove configuration from'}:", agent_items
) 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]
chosen_integrations = _checkbox(
f"Select integrations to {'enable' if install else 'remove'}:", integ_items
) 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]
chosen_integrations = _checkbox(
f"Select integrations to {'enable' if install else 'remove'}:", integ_items
) or _exit("Nothing selected. Exiting.")

_print_plan(chosen_agents, chosen_integrations)

question = "Proceed?" if install else "Remove semble configuration?"
if not questionary.confirm(question, default=install).ask():
_exit("Cancelled.")
if not yes:
question = "Proceed?" if install else "Remove semble configuration?"
if not questionary.confirm(question, default=install).ask():
_exit("Cancelled.")

_apply(mode, chosen_agents, chosen_integrations)
footer = " Restart your agents to pick up the changes." if install else ""
Expand Down
53 changes: 51 additions & 2 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AGENTS,
SEMBLE_END,
SEMBLE_START,
IntegrationType,
_opencode_mcp_path,
_vscode_mcp_path,
is_detected,
Expand Down Expand Up @@ -470,7 +471,55 @@ def test_cli_dispatches_to_installer_run(monkeypatch, command):
import semble.cli as cli

calls = []
monkeypatch.setattr("semble.installer.run", lambda mode: calls.append(mode))
monkeypatch.setattr("semble.installer.run", lambda mode, **kwargs: calls.append((mode, kwargs)))
monkeypatch.setattr(sys, "argv", ["semble", command])
cli.main()
assert calls == [command]
assert calls == [(command, {"agent_ids": None, "integration_ids": None, "yes": False})]


@pytest.mark.parametrize("command", ["install", "uninstall"])
def test_cli_unattended_flags(monkeypatch, command):
"""--agent/--type/--yes flags run non-interactively without prompting."""
import semble.cli as cli

calls = []
monkeypatch.setattr("semble.installer.run", lambda mode, **kwargs: calls.append((mode, kwargs)))
monkeypatch.setattr(sys, "argv", ["semble", command, "--agent", "pi", "--type", "subagent", "--yes"])
cli.main()
assert calls == [(command, {"agent_ids": ["pi"], "integration_ids": [IntegrationType.SUBAGENT], "yes": True})]
assert isinstance(calls[0][1]["integration_ids"][0], IntegrationType)


def test_cli_type_without_agent_errors(monkeypatch, capsys):
"""--type without --agent is rejected with a usage error."""
import semble.cli as cli

monkeypatch.setattr(sys, "argv", ["semble", "install", "--type", "mcp"])
with pytest.raises(SystemExit):
cli.main()
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."""
from semble.installer import run

def _boom(*_: object, **__: object) -> None:
raise AssertionError("should not prompt in unattended mode")

monkeypatch.setattr("semble.installer.installer._checkbox", _boom)
monkeypatch.setattr("semble.installer.installer.questionary.confirm", _boom)
run("install", agent_ids=["claude"], yes=True)


@pytest.mark.parametrize(
("agent_ids", "integration_ids"),
[([], None), (["nonexistent"], None), (["claude"], [])],
)
def test_run_unattended_empty_selection_exits(run_setup, agent_ids, integration_ids):
"""run() exits with a non-zero code instead of silently no-opping when nothing matches."""
from semble.installer import run

with pytest.raises(SystemExit) as exc_info:
run("install", agent_ids=agent_ids, integration_ids=integration_ids, yes=True)
assert exc_info.value.code == 1
Loading