From c80647401e78544eec28d000be74a18e5be7ef55 Mon Sep 17 00:00:00 2001 From: Pringled Date: Thu, 2 Jul 2026 09:37:19 +0200 Subject: [PATCH 01/10] feat: support unattended install/uninstall via --agent/--type/--yes Adds non-interactive install flags for sandboxed/scripted environments (closes #212), e.g. `semble install --agent pi --type subagent --yes`. --- README.md | 8 +++++ docs/installation.md | 14 +++++++++ src/semble/cli.py | 32 ++++++++++++++++++-- src/semble/installer/agents.py | 2 ++ src/semble/installer/installer.py | 50 ++++++++++++++++++++----------- tests/test_installer.py | 36 ++++++++++++++++++++-- 6 files changed, 120 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index babb7edf1..50ec99c66 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ semble install To undo the setup, run `semble uninstall`. +For unattended installs (e.g. 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. + For manual setup instructions (MCP config per agent, AGENTS.md snippet, sub-agent files), see the [installation docs](docs/installation.md).
diff --git a/docs/installation.md b/docs/installation.md index 10820cecc..7a6a46784 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. + +`semble uninstall` accepts the same flags. + --- ## Manual setup diff --git a/src/semble/cli.py b/src/semble/cli.py index 544084ce3..2c11331e2 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, INTEGRATION_IDS 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=[*INTEGRATION_IDS, "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.", + ) 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 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..f02686d48 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -12,6 +12,8 @@ Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"] Mode = Literal["install", "uninstall"] +INTEGRATION_IDS = ("mcp", "instructions", "subagent") + SEMBLE_START = "" SEMBLE_END = "" diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index dcbc1891a..91167b229 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -178,30 +178,46 @@ 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[str] | 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] + chosen_integrations = ( + [i for i in _INTEGRATIONS if i.id in integration_ids] if integration_ids else list(_INTEGRATIONS) + ) + 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..e7ba75371 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -470,7 +470,39 @@ 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": ["subagent"], "yes": True})] + + +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 + + monkeypatch.setattr( + "semble.installer.installer.questionary.confirm", lambda *_, **__: (_ for _ in ()).throw(AssertionError) + ) + run("install", agent_ids=["claude"], yes=True) From 55d1b8f15a6b1c9e8af95cf2378b5a00d2b5c000 Mon Sep 17 00:00:00 2001 From: Pringled Date: Thu, 2 Jul 2026 09:40:48 +0200 Subject: [PATCH 02/10] docs: move unattended install to collapsible section in Quickstart --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 50ec99c66..28a5803b8 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,6 @@ semble install To undo the setup, run `semble uninstall`. -For unattended installs (e.g. 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. - For manual setup instructions (MCP config per agent, AGENTS.md snippet, sub-agent files), see the [installation docs](docs/installation.md).
@@ -65,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. + +
+ ## Main Features - **Fast**: indexes an average repo in ~250 ms and answers queries in ~1.5 ms, all on CPU. From edf1d61e96c65fd4ddca14d304b1cd88b8dd2d57 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 07:54:00 +0200 Subject: [PATCH 03/10] fix: exit instead of silently no-op on empty unattended agent/type selection run() previously took the unattended branch whenever agent_ids was not None, including an empty list, printing a blank plan and returning success. Mirror the interactive path's guard. --- src/semble/installer/installer.py | 4 ++-- tests/test_installer.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 91167b229..2ddae9689 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -193,10 +193,10 @@ def run( print(f"\n {_BOLD}{'Semble Installer' if install else 'Semble Uninstaller'}{_RESET}\n") if agent_ids is not None: - chosen_agents = [a for a in AGENTS if a.id in agent_ids] + chosen_agents = [a for a in AGENTS if a.id in agent_ids] or _exit("No matching agents. Exiting.") chosen_integrations = ( [i for i in _INTEGRATIONS if i.id in integration_ids] if integration_ids else list(_INTEGRATIONS) - ) + ) or _exit("No matching integrations. Exiting.") else: agent_items = [ (f"{a.display_name}{' (detected)' if (detected := is_detected(a)) else ''}", a, detected and install) diff --git a/tests/test_installer.py b/tests/test_installer.py index e7ba75371..5f5aa152a 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -506,3 +506,15 @@ def test_run_unattended_skips_prompts(run_setup, monkeypatch): "semble.installer.installer.questionary.confirm", lambda *_, **__: (_ for _ in ()).throw(AssertionError) ) run("install", agent_ids=["claude"], yes=True) + + +@pytest.mark.parametrize( + ("agent_ids", "integration_ids"), + [([], None), (["nonexistent"], None), (["claude"], ["nonexistent"])], +) +def test_run_unattended_empty_selection_exits(run_setup, agent_ids, integration_ids): + """run() exits cleanly instead of silently no-opping when agent_ids/integration_ids match nothing.""" + from semble.installer import run + + with pytest.raises(SystemExit): + run("install", agent_ids=agent_ids, integration_ids=integration_ids, yes=True) From 2d50c3f6ff596c5b8d9cce61060e02bce3d90948 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 08:06:46 +0200 Subject: [PATCH 04/10] refactor: type integration ids as an IntegrationId str enum Matches the existing ContentType(str, Enum) pattern in types.py, giving mypy real coverage on run()'s integration_ids for direct API callers instead of an unconstrained list[str]. --- src/semble/cli.py | 6 +++--- src/semble/installer/agents.py | 10 +++++++++- src/semble/installer/installer.py | 15 ++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/semble/cli.py b/src/semble/cli.py index 2c11331e2..1178cb7d6 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -13,7 +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, INTEGRATION_IDS +from semble.installer.agents import AGENTS, IntegrationId from semble.stats import format_savings_report from semble.types import ContentType from semble.utils import format_results, is_git_url, resolve_chunk @@ -213,7 +213,7 @@ def _cli_main() -> None: p.add_argument( "--type", nargs="+", - choices=[*INTEGRATION_IDS, "all"], + choices=[*(t.value for t in IntegrationId), "all"], metavar="TYPE", help="Integrations to include (mcp, instructions, subagent, or all). Default: all. Requires --agent.", ) @@ -234,7 +234,7 @@ def _cli_main() -> None: from semble.installer import run - integration_ids = None if not args.type or "all" in args.type else args.type + integration_ids = None if not args.type or "all" in args.type else [IntegrationId(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 f02686d48..1fc0ef5ae 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,7 +13,14 @@ Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"] Mode = Literal["install", "uninstall"] -INTEGRATION_IDS = ("mcp", "instructions", "subagent") + +class IntegrationId(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 2ddae9689..e39d4355c 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -12,6 +12,7 @@ AGENTS, INSTRUCTIONS, AgentTarget, + IntegrationId, Mode, WriteResult, is_detected, @@ -41,7 +42,7 @@ class _Integration: """Descriptor for one installer integration (MCP server, instructions, sub-agent).""" - id: str + id: IntegrationId 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 + IntegrationId.MCP, + "MCP server", + "lets the agent call semble directly as a tool", + _apply_mcp, + AgentTarget.resolved_mcp_path, ), _Integration( - "instructions", + IntegrationId.INSTRUCTIONS, "Instructions", "adds CLI usage guidance to AGENTS.md / CLAUDE.md", _apply_instructions, lambda a: a.instructions_path, ), _Integration( - "subagent", + IntegrationId.SUBAGENT, "Sub-agent", "installs a dedicated semble-search sub-agent", _apply_subagent, @@ -181,7 +186,7 @@ def _apply(mode: Mode, agents: list[AgentTarget], integrations: list[_Integratio def run( mode: Mode, agent_ids: list[str] | None = None, - integration_ids: list[str] | None = None, + integration_ids: list[IntegrationId] | None = None, yes: bool = False, ) -> None: """Install or uninstall semble across coding agents. From f1baed400a9a552665f9d978d304805a03523a1e Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 08:09:49 +0200 Subject: [PATCH 05/10] rename: IntegrationId -> IntegrationType, to match ContentType naming --- src/semble/cli.py | 6 +++--- src/semble/installer/agents.py | 2 +- src/semble/installer/installer.py | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/semble/cli.py b/src/semble/cli.py index 1178cb7d6..c437899de 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -13,7 +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, IntegrationId +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 @@ -213,7 +213,7 @@ def _cli_main() -> None: p.add_argument( "--type", nargs="+", - choices=[*(t.value for t in IntegrationId), "all"], + choices=[*(t.value for t in IntegrationType), "all"], metavar="TYPE", help="Integrations to include (mcp, instructions, subagent, or all). Default: all. Requires --agent.", ) @@ -234,7 +234,7 @@ def _cli_main() -> None: from semble.installer import run - integration_ids = None if not args.type or "all" in args.type else [IntegrationId(t) for t in args.type] + 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) diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index 1fc0ef5ae..3d51ab3b4 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -14,7 +14,7 @@ Mode = Literal["install", "uninstall"] -class IntegrationId(str, Enum): +class IntegrationType(str, Enum): """Identifier for one of semble's install/uninstall integrations.""" MCP = "mcp" diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index e39d4355c..077bfeafe 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -12,7 +12,7 @@ AGENTS, INSTRUCTIONS, AgentTarget, - IntegrationId, + IntegrationType, Mode, WriteResult, is_detected, @@ -42,7 +42,7 @@ class _Integration: """Descriptor for one installer integration (MCP server, instructions, sub-agent).""" - id: IntegrationId + id: IntegrationType label: str desc: str apply: Callable[[AgentTarget, Mode], WriteResult | None] @@ -104,21 +104,21 @@ def _apply_subagent(agent: AgentTarget, mode: Mode) -> WriteResult | None: _INTEGRATIONS: list[_Integration] = [ _Integration( - IntegrationId.MCP, + IntegrationType.MCP, "MCP server", "lets the agent call semble directly as a tool", _apply_mcp, AgentTarget.resolved_mcp_path, ), _Integration( - IntegrationId.INSTRUCTIONS, + IntegrationType.INSTRUCTIONS, "Instructions", "adds CLI usage guidance to AGENTS.md / CLAUDE.md", _apply_instructions, lambda a: a.instructions_path, ), _Integration( - IntegrationId.SUBAGENT, + IntegrationType.SUBAGENT, "Sub-agent", "installs a dedicated semble-search sub-agent", _apply_subagent, @@ -186,7 +186,7 @@ def _apply(mode: Mode, agents: list[AgentTarget], integrations: list[_Integratio def run( mode: Mode, agent_ids: list[str] | None = None, - integration_ids: list[IntegrationId] | None = None, + integration_ids: list[IntegrationType] | None = None, yes: bool = False, ) -> None: """Install or uninstall semble across coding agents. From aff86bdb42d28d00e3f7bf7345120617a6de4bb7 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 08:17:40 +0200 Subject: [PATCH 06/10] fix: distinguish integration_ids=[] from None in run() An explicit empty list previously fell through to "all integrations" because of a falsy check; it should exit like agent_ids=[] does. The CLI can't trigger this (argparse requires nargs="+"), but direct API callers could. --- src/semble/installer/installer.py | 4 +++- tests/test_installer.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 077bfeafe..0dae846d6 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -200,7 +200,9 @@ def run( 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.") chosen_integrations = ( - [i for i in _INTEGRATIONS if i.id in integration_ids] if integration_ids else list(_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.") else: agent_items = [ diff --git a/tests/test_installer.py b/tests/test_installer.py index 5f5aa152a..5558bb594 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -510,7 +510,7 @@ def test_run_unattended_skips_prompts(run_setup, monkeypatch): @pytest.mark.parametrize( ("agent_ids", "integration_ids"), - [([], None), (["nonexistent"], None), (["claude"], ["nonexistent"])], + [([], None), (["nonexistent"], None), (["claude"], ["nonexistent"]), (["claude"], [])], ) def test_run_unattended_empty_selection_exits(run_setup, agent_ids, integration_ids): """run() exits cleanly instead of silently no-opping when agent_ids/integration_ids match nothing.""" From b5c2a2170a79999654cb2e41e977f1609fca560c Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 08:29:19 +0200 Subject: [PATCH 07/10] docs+test: clarify --yes requires --agent for non-interactive; assert IntegrationType conversion --yes alone still hits the interactive checkbox path (hangs in non-TTY), so document that. Also strengthen the unattended-flags test to check the CLI actually converts --type strings to IntegrationType, not just equal-by-value strings. --- src/semble/cli.py | 2 +- tests/test_installer.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/semble/cli.py b/src/semble/cli.py index c437899de..562549d20 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -221,7 +221,7 @@ def _cli_main() -> None: "-y", "--yes", action="store_true", - help="Skip the confirmation prompt.", + help="Skip the confirmation prompt. Combine with --agent for a fully non-interactive run.", ) args = parser.parse_args() diff --git a/tests/test_installer.py b/tests/test_installer.py index 5558bb594..568e0023d 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, @@ -485,7 +486,8 @@ def test_cli_unattended_flags(monkeypatch, command): 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": ["subagent"], "yes": True})] + 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): From b55eb42f4a32b651b6f2e4dadcbb73240de05786 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 08:45:46 +0200 Subject: [PATCH 08/10] docs: clarify --yes requires --agent for a fully non-interactive run --- README.md | 2 +- docs/installation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28a5803b8..9286a8d8a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,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. +`--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).
diff --git a/docs/installation.md b/docs/installation.md index 7a6a46784..6c7e350dd 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`, or `all` (default: all). Requires `--agent`. -- `-y`/`--yes` — skip the confirmation prompt. +- `-y`/`--yes` — skip the confirmation prompt. Requires `--agent` for a fully non-interactive run. `semble uninstall` accepts the same flags. From 19844e26e334d740525a9b2e8ef7796567a0fc6c Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 09:02:19 +0200 Subject: [PATCH 09/10] fix: exit non-zero when unattended install matches no agents/integrations _exit() previously always used code 0, so scripts checking $? after an unattended run() call would see success even when nothing matched --agent/--type. The interactive cancel/nothing-selected paths keep exiting 0 since those reflect a deliberate user choice, not an error. --- src/semble/installer/installer.py | 10 +++++----- tests/test_installer.py | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 0dae846d6..7d6cdecad 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -132,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: @@ -198,12 +198,12 @@ def run( print(f"\n {_BOLD}{'Semble Installer' if install else 'Semble Uninstaller'}{_RESET}\n") 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.") + 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.") + ) 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) diff --git a/tests/test_installer.py b/tests/test_installer.py index 568e0023d..1945ac107 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -515,8 +515,9 @@ def test_run_unattended_skips_prompts(run_setup, monkeypatch): [([], None), (["nonexistent"], None), (["claude"], ["nonexistent"]), (["claude"], [])], ) def test_run_unattended_empty_selection_exits(run_setup, agent_ids, integration_ids): - """run() exits cleanly instead of silently no-opping when agent_ids/integration_ids match nothing.""" + """run() exits with a non-zero code instead of silently no-opping when nothing matches.""" from semble.installer import run - with pytest.raises(SystemExit): + 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 From a1e6e6744f6b0c1e2ded6e015512ee3a5b607115 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 3 Jul 2026 09:12:25 +0200 Subject: [PATCH 10/10] test: tighten unattended-path tests - test_run_unattended_skips_prompts now also asserts _checkbox is never called, not just questionary.confirm, so a branching regression that fell through to the interactive path would be caught. - Drop the integration_ids=["nonexistent"] case: it's unreachable via the typed API (IntegrationType is a closed enum; only an empty list can yield zero matches), so it tested runtime string handling rather than the real contract. --- tests/test_installer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 1945ac107..3da295d91 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -504,15 +504,17 @@ 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 - monkeypatch.setattr( - "semble.installer.installer.questionary.confirm", lambda *_, **__: (_ for _ in ()).throw(AssertionError) - ) + 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"], ["nonexistent"]), (["claude"], [])], + [([], 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."""