diff --git a/README.md b/README.md index babb7edf1..9286a8d8a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,19 @@ uv cache clean semble # for MCP users (restart your MCP client after) +
+Unattended install + +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). + +
+ ## Main Features - **Fast**: indexes an average repo in ~250 ms and answers queries in ~1.5 ms, all on CPU. diff --git a/docs/installation.md b/docs/installation.md index 10820cecc..6c7e350dd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/semble/cli.py b/src/semble/cli.py index 544084ce3..562549d20 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -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 @@ -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] + 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": diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 9ac05c185..3d51ab3b4 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -4,6 +4,7 @@ import shutil import sys from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import Literal @@ -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_END = "" diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index dcbc1891a..7d6cdecad 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -12,6 +12,7 @@ AGENTS, INSTRUCTIONS, AgentTarget, + IntegrationType, Mode, WriteResult, is_detected, @@ -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] @@ -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, @@ -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: @@ -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 "" diff --git a/tests/test_installer.py b/tests/test_installer.py index b683841f3..3da295d91 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -10,6 +10,7 @@ AGENTS, SEMBLE_END, SEMBLE_START, + IntegrationType, _opencode_mcp_path, _vscode_mcp_path, is_detected, @@ -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