diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ee160413edc..c69a0b882bb 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2855,6 +2855,7 @@ def run_setup_wizard(args): [ "Quick Setup (Nous Portal) — free OAuth login, no API keys, model + tools (recommended)", "Full setup — configure every provider, tool & option yourself (bring your own keys)", + "Blank Slate — everything off except the bare minimum; opt in to each capability", ], 0, ) @@ -2862,6 +2863,9 @@ def run_setup_wizard(args): if setup_mode == 0: _run_first_time_quick_setup(config, hermes_home, is_existing) return + if setup_mode == 2: + _run_blank_slate_setup(config, hermes_home, is_existing) + return # ── Full Setup — run all sections ── print_header("Configuration Location") @@ -2982,6 +2986,237 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): _print_setup_summary(config, hermes_home) +def _blank_slate_minimal_toolsets(config: dict): + """Write the minimal toolset state for a Blank Slate install. + + Only ``file`` and ``terminal`` are enabled. Two layers enforce this: + + 1. ``platform_toolsets["cli"] = ["file", "terminal"]`` — an explicit list of + configurable keys, which the resolver treats as authoritative + (``has_explicit_config``) so default toolsets aren't re-expanded. + 2. ``agent.disabled_toolsets`` — a global hard-suppression list (applied last + in ``_get_platform_tools``, overriding every other path including the + non-configurable platform-toolset recovery that would otherwise re-add + toolsets like ``kanban``). We list every known toolset except the two we + keep, guaranteeing a true blank slate regardless of platform/recovery + quirks. The user re-enables any of them later via ``hermes tools`` (which + rewrites ``platform_toolsets``) or by editing ``agent.disabled_toolsets``. + """ + keep = {"file", "terminal"} + config.setdefault("platform_toolsets", {})["cli"] = sorted(keep) + + try: + from toolsets import TOOLSETS + from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_plugin_toolset_keys + + all_keys = set() + all_keys.update(k for k, _, _ in CONFIGURABLE_TOOLSETS) + all_keys.update(_get_plugin_toolset_keys()) + # Plain (non-composite) TOOLSETS entries — catches recovered toolsets + # like ``kanban`` that aren't in CONFIGURABLE_TOOLSETS but get re-added. + for k, tdef in TOOLSETS.items(): + if k.startswith("hermes-"): + continue # platform composites — not user-facing toolsets + if isinstance(tdef, dict) and tdef.get("includes"): + continue # composite groupings, not leaf toolsets + all_keys.add(k) + + disabled = sorted(all_keys - keep) + if disabled: + config.setdefault("agent", {})["disabled_toolsets"] = disabled + except Exception as exc: + logger.debug("blank-slate disabled_toolsets computation skipped: %s", exc) + + +def _blank_slate_minimize_config(config: dict): + """Turn OFF the optional config features for a Blank Slate install. + + Everything here is opt-in afterwards via ``hermes setup agent`` / + ``hermes config set``. We keep only what's needed to run. + """ + config.setdefault("agent", {})["max_turns"] = 90 + + # Compression off — minimal footprint; user opts in if they want long sessions. + config.setdefault("compression", {})["enabled"] = False + + # No automatic memory / user-profile capture. + mem = config.setdefault("memory", {}) + mem["memory_enabled"] = False + mem["user_profile_enabled"] = False + + # No filesystem checkpoints, no smart model routing, no auto session reset. + config.setdefault("checkpoints", {})["enabled"] = False + config.setdefault("smart_model_routing", {})["enabled"] = False + config.setdefault("session_reset", {})["mode"] = "none" + + # Quiet, minimal display. + config.setdefault("display", {})["tool_progress"] = "all" + + +def _run_blank_slate_setup(config: dict, hermes_home, is_existing: bool): + """Blank Slate setup — start with everything off except the bare minimum. + + Forces only the essentials to run an agent (provider + model, the file and + terminal toolsets) and turns every other tool/skill/plugin/MCP/config + feature OFF. After applying that minimal baseline, the user chooses one of + two paths: + + 1. Start with everything disabled — finish now with the minimal agent. + 2. Walk through every configuration — opt each capability back in. + + Either way nothing is enabled that the user did not explicitly choose. + """ + from hermes_cli.config import load_config + + print() + print_header("Blank Slate Setup") + print_info("Everything starts OFF. First we force-enable only what's required") + print_info("to run an agent, then you choose whether to stop there or walk") + print_info("through enabling more — opting in to exactly what you want.") + print_info("") + print_info("Forced on: Provider & Model, File Operations, Terminal.") + print_info("Everything else (web, browser, code exec, vision, memory,") + print_info("delegation, cron, skills, plugins, MCP, …) starts disabled.") + print() + + # ── Step 1: Provider & Model (REQUIRED — the agent cannot run without it) ── + print_header("Step 1 — Provider & Model (required)") + setup_model_provider(config) + save_config(config) + + # ── Step 2: Terminal backend (where commands run — a core decision) ── + print_header("Step 2 — Terminal Backend") + setup_terminal_backend(config) + + # ── Step 3: Lock in the minimal toolset + minimized config knobs ── + _blank_slate_minimal_toolsets(config) + _blank_slate_minimize_config(config) + save_config(config) + print() + print_success("Minimal baseline applied:") + print_info(" Toolsets: file, terminal (everything else off)") + print_info(" Compression, memory, checkpoints, smart routing: off") + + # ── The fork: stop here, or walk through enabling things ── + print() + print_header("How far do you want to go?") + path = prompt_choice( + "Your minimal agent is ready. What next?", + [ + "Start with everything disabled — finish now (most minimal)", + "Walk through all configurations — opt in to tools, skills, plugins, MCP", + ], + 0, + ) + + if path == 0: + save_config(config) + # Blank Slate means no bundled skills; record the opt-out so future + # `hermes update` runs don't re-inject them. + try: + from tools.skills_sync import set_bundled_skills_opt_out + set_bundled_skills_opt_out(True) + except Exception as exc: + logger.debug("blank-slate skill opt-out error: %s", exc) + print() + print_success("Blank Slate setup complete — minimal agent ready.") + print_info("Enable anything later, on demand:") + print_info(" Enable tools: hermes tools") + print_info(" Seed skills: hermes skills opt-in --sync") + print_info(" Add MCP servers: hermes mcp add") + print_info(" Enable plugins: hermes plugins") + print_info(" Tune agent settings: hermes setup agent") + print() + _print_setup_summary(config, hermes_home) + return + + # ── Walkthrough path — opt in to each capability ── + _blank_slate_walkthrough(config, hermes_home) + + +def _blank_slate_walkthrough(config: dict, hermes_home): + """Opt-in walkthrough for Blank Slate: skills, tools, plugins, MCP, gateway.""" + from hermes_cli.config import load_config + + # ── Bundled skills — default to NONE, offer to seed all ── + print() + print_header("Bundled Skills") + print_info("Blank Slate ships with NO bundled skills by default.") + seed_skills = prompt_yes_no( + "Seed the full bundled skill catalog? (No = start with zero skills)", + default=False, + ) + try: + from tools.skills_sync import set_bundled_skills_opt_out, sync_skills + if seed_skills: + # Make sure no stale opt-out marker blocks the seed, then sync. + set_bundled_skills_opt_out(False) + result = sync_skills(quiet=True) + copied = len(result.get("copied", [])) if isinstance(result, dict) else 0 + print_success(f"Seeded {copied} bundled skills.") + else: + set_bundled_skills_opt_out(True) + print_info("No skills seeded. A .no-bundled-skills marker keeps future") + print_info("`hermes update` runs from re-injecting them. Opt back in any") + print_info("time with `hermes skills opt-in --sync`.") + except Exception as exc: + logger.debug("blank-slate skill handling error: %s", exc) + print_warning(f"Skill setup step encountered an error: {exc}") + + # ── Walk through enabling additional tools ── + print() + print_header("Tools") + print_info("Pick exactly which additional toolsets to turn on.") + print_info("(file and terminal are already on; leave the rest off if you want") + print_info(" the most minimal agent.)") + if prompt_yes_no("Open the tool selector to enable more tools?", default=False): + try: + from hermes_cli.tools_config import tools_command + tools_command(first_install=False, config=config) + # tools_command saves via its own load/save cycle — re-sync. + _refreshed = load_config() + config.clear() + config.update(_refreshed) + except Exception as exc: + logger.debug("blank-slate tools_command error: %s", exc) + print_warning(f"Tool selector encountered an error: {exc}") + else: + print_info("Keeping the minimal toolset. Add tools later with `hermes tools`.") + + # ── Built-in plugins (off unless chosen) ── + print() + print_header("Plugins") + if prompt_yes_no("Review and enable built-in plugins now?", default=False): + print_info("Manage plugins with `hermes plugins list` / `hermes plugins install`.") + else: + print_info("No plugins enabled. Add later with `hermes plugins`.") + + # ── MCP servers (off unless chosen) ── + print() + print_header("MCP Servers") + if prompt_yes_no("Add an MCP server now?", default=False): + print_info("Add servers with `hermes mcp add --url ... | --command ...`.") + else: + print_info("No MCP servers configured. Add later with `hermes mcp add`.") + + # ── Optional messaging gateway ── + print() + if prompt_yes_no("Connect a messaging platform (Telegram, Discord, …)?", default=False): + setup_gateway(config) + + save_config(config) + + print() + print_success("Blank Slate setup complete — minimal agent ready.") + print_info(" Enable more tools: hermes tools") + print_info(" Seed skills: hermes skills opt-in --sync") + print_info(" Add MCP servers: hermes mcp add") + print_info(" Tune agent settings: hermes setup agent") + print() + + _print_setup_summary(config, hermes_home) + + def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" from hermes_cli.config import ( diff --git a/tests/hermes_cli/test_setup_blank_slate.py b/tests/hermes_cli/test_setup_blank_slate.py new file mode 100644 index 00000000000..a62cf9a2250 --- /dev/null +++ b/tests/hermes_cli/test_setup_blank_slate.py @@ -0,0 +1,131 @@ +"""Tests for Blank Slate setup mode (hermes_cli/setup.py). + +Blank Slate is the third first-time setup option: everything off except the +bare minimum needed to run an agent (provider/model + file + terminal). These +tests pin the config the writers produce and the invariant that the toolset +resolver + tool-schema builder yield exactly the file/terminal tools. +""" + +import pytest + +from hermes_cli.setup import ( + _blank_slate_minimal_toolsets, + _blank_slate_minimize_config, +) + + +class TestBlankSlateMinimalToolsets: + def test_only_file_and_terminal_enabled_for_cli(self): + cfg = {} + _blank_slate_minimal_toolsets(cfg) + assert cfg["platform_toolsets"]["cli"] == ["file", "terminal"] + + def test_disabled_toolsets_excludes_kept_and_covers_known(self): + cfg = {} + _blank_slate_minimal_toolsets(cfg) + disabled = set(cfg["agent"]["disabled_toolsets"]) + # The two kept toolsets must NOT be in the disabled list. + assert "file" not in disabled + assert "terminal" not in disabled + # A representative spread of capabilities must be suppressed. + for ts in ("web", "browser", "code_execution", "vision", "memory", + "delegation", "cronjob", "skills", "image_gen"): + assert ts in disabled + # The recovered non-configurable toolset that used to leak is suppressed. + assert "kanban" in disabled + + def test_resolver_yields_exactly_file_and_terminal(self): + from hermes_cli.tools_config import _get_platform_tools + cfg = {} + _blank_slate_minimal_toolsets(cfg) + _blank_slate_minimize_config(cfg) + resolved = set(_get_platform_tools(cfg, "cli")) + assert resolved == {"file", "terminal"} + + def test_tool_schema_builder_yields_only_file_and_terminal_tools(self): + # End-to-end: the exact schema set the agent would send to the model. + import model_tools + from hermes_cli.tools_config import _get_platform_tools + cfg = {} + _blank_slate_minimal_toolsets(cfg) + _blank_slate_minimize_config(cfg) + enabled = sorted(_get_platform_tools(cfg, "cli")) + defs = model_tools.get_tool_definitions( + enabled_toolsets=enabled, disabled_toolsets=None, quiet_mode=True + ) + names = sorted( + {(d.get("function") or {}).get("name") or d.get("name") for d in defs} + ) + assert names == ["patch", "process", "read_file", "search_files", + "terminal", "write_file"] + + +class TestBlankSlateMinimizeConfig: + def test_optional_features_turned_off(self): + cfg = {} + _blank_slate_minimize_config(cfg) + assert cfg["compression"]["enabled"] is False + assert cfg["memory"]["memory_enabled"] is False + assert cfg["memory"]["user_profile_enabled"] is False + assert cfg["checkpoints"]["enabled"] is False + assert cfg["smart_model_routing"]["enabled"] is False + assert cfg["session_reset"]["mode"] == "none" + + def test_does_not_clobber_unrelated_keys(self): + cfg = {"model": {"provider": "openrouter", "default": "x/y"}} + _blank_slate_minimize_config(cfg) + # Model config is untouched by the minimizer. + assert cfg["model"]["provider"] == "openrouter" + assert cfg["model"]["default"] == "x/y" + + +class TestBlankSlateFork: + """The post-baseline fork: finish now vs walk through configurations.""" + + def _patch_common(self, monkeypatch): + import hermes_cli.setup as s + # Neutralize side-effecting setup steps and I/O. + monkeypatch.setattr(s, "setup_model_provider", lambda cfg, **k: None) + monkeypatch.setattr(s, "setup_terminal_backend", lambda cfg, **k: None) + monkeypatch.setattr(s, "save_config", lambda cfg: None) + monkeypatch.setattr(s, "_print_setup_summary", lambda cfg, home: None) + monkeypatch.setattr(s, "print_header", lambda *a, **k: None) + monkeypatch.setattr(s, "print_info", lambda *a, **k: None) + monkeypatch.setattr(s, "print_success", lambda *a, **k: None) + monkeypatch.setattr(s, "print_warning", lambda *a, **k: None) + + def test_finish_now_skips_walkthrough(self, monkeypatch, tmp_path): + import hermes_cli.setup as s + self._patch_common(monkeypatch) + # Fork prompt returns 0 = finish now. + monkeypatch.setattr(s, "prompt_choice", lambda *a, **k: 0) + walked = {"called": False} + monkeypatch.setattr(s, "_blank_slate_walkthrough", + lambda cfg, home: walked.__setitem__("called", True)) + opted_out = {"value": None} + monkeypatch.setattr("tools.skills_sync.set_bundled_skills_opt_out", + lambda enabled: opted_out.__setitem__("value", enabled)) + + cfg = {} + s._run_blank_slate_setup(cfg, tmp_path, is_existing=False) + + # Minimal baseline was applied, walkthrough was NOT run. + assert cfg["platform_toolsets"]["cli"] == ["file", "terminal"] + assert walked["called"] is False + # Finish-now path records the skill opt-out (no bundled skills). + assert opted_out["value"] is True + + def test_walkthrough_path_invokes_walkthrough(self, monkeypatch, tmp_path): + import hermes_cli.setup as s + self._patch_common(monkeypatch) + # Fork prompt returns 1 = walk through. + monkeypatch.setattr(s, "prompt_choice", lambda *a, **k: 1) + walked = {"called": False} + monkeypatch.setattr(s, "_blank_slate_walkthrough", + lambda cfg, home: walked.__setitem__("called", True)) + + cfg = {} + s._run_blank_slate_setup(cfg, tmp_path, is_existing=False) + + assert cfg["platform_toolsets"]["cli"] == ["file", "terminal"] + assert walked["called"] is True diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 630df6e2938..f348828a55f 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -95,6 +95,16 @@ hermes setup --portal That logs you in, sets Nous as your provider, and turns on the Tool Gateway in one command. ::: +:::info Setup modes +On a fresh install, `hermes setup` offers three modes: + +- **Quick Setup (Nous Portal)** — free OAuth login, no API keys; sets up a model plus the Tool Gateway tools. The recommended fast path. +- **Full Setup** — walk through every provider, tool, and option yourself (bring your own keys). +- **Blank Slate** — everything starts **off** except the bare minimum needed to run an agent: **provider & model, the File Operations toolset, and the Terminal toolset**. No web, browser, code execution, vision, memory, delegation, cron, skills, plugins, or MCP servers — and compression, checkpoints, smart routing, and memory capture are all disabled. After the minimal baseline is applied, you choose one of two paths: **start with everything disabled** (finish now with the minimal agent), or **walk through all configurations** (opt in to tools, skills, plugins, MCP, and messaging). Pick this when you want a minimal, fully-controlled agent and intend to enable only exactly what you need. + +Blank Slate writes an explicit `platform_toolsets.cli` list plus `agent.disabled_toolsets`, so nothing you didn't choose ever loads — not even after `hermes update`. Re-enable anything later with `hermes tools`, seed skills with `hermes skills opt-in --sync`, or tune settings with `hermes setup agent`. +::: + Good defaults: | Provider | What it is | How to set up |