diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index 1f1747f4454..63546614261 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -23,7 +23,11 @@ import sys from pathlib import Path -def _build_full_manifest(bot_name: str, bot_description: str) -> dict: +def _build_full_manifest( + bot_name: str, + bot_description: str, + include_assistant: bool = True, +) -> dict: """Build a full Slack manifest merging display info + our slash list. The slash-command list is always generated from ``COMMAND_REGISTRY`` so @@ -31,12 +35,71 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: (display info, OAuth scopes, socket mode) are set to sensible defaults for a Hermes deployment — users can tweak them in the Slack UI after pasting. + + When ``include_assistant`` is True (default) the manifest opts the app + into Slack's AI Assistant container: the ``assistant_view`` feature, the + ``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack + then renders DMs as the right-hand Assistant split-pane, where every + exchange is a thread and bare slash commands are not delivered as normal + ``command`` events. Pass ``include_assistant=False`` (``--no-assistant``) + to omit those three pieces and get a flat DM surface where ``/help``, + ``/new``, etc. work inline. """ from hermes_cli.commands import slack_app_manifest partial = slack_app_manifest() slashes = partial["features"]["slash_commands"] + features = { + "app_home": { + "home_tab_enabled": False, + "messages_tab_enabled": True, + "messages_tab_read_only_enabled": False, + }, + "bot_user": { + "display_name": bot_name[:80], + "always_online": True, + }, + "slash_commands": slashes, + } + + bot_scopes = [ + "app_mentions:read", + "channels:history", + "channels:read", + "chat:write", + "commands", + "files:read", + "files:write", + "groups:history", + "groups:read", + "im:history", + "im:read", + "im:write", + "users:read", + ] + + bot_events = [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + ] + + if include_assistant: + features["assistant_view"] = { + "assistant_description": "Chat with Hermes in threads and DMs.", + } + bot_scopes.append("assistant:write") + bot_events.extend( + [ + "assistant_thread_context_changed", + "assistant_thread_started", + ] + ) + bot_scopes.sort() + bot_events.sort() + return { "_metadata": { "major_version": 1, @@ -47,51 +110,15 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: "description": (bot_description or "Your Hermes agent on Slack")[:140], "background_color": "#1a1a2e", }, - "features": { - "app_home": { - "home_tab_enabled": False, - "messages_tab_enabled": True, - "messages_tab_read_only_enabled": False, - }, - "bot_user": { - "display_name": bot_name[:80], - "always_online": True, - }, - "slash_commands": slashes, - "assistant_view": { - "assistant_description": "Chat with Hermes in threads and DMs.", - }, - }, + "features": features, "oauth_config": { "scopes": { - "bot": [ - "app_mentions:read", - "assistant:write", - "channels:history", - "channels:read", - "chat:write", - "commands", - "files:read", - "files:write", - "groups:history", - "groups:read", - "im:history", - "im:read", - "im:write", - "users:read", - ], + "bot": bot_scopes, }, }, "settings": { "event_subscriptions": { - "bot_events": [ - "app_mention", - "assistant_thread_context_changed", - "assistant_thread_started", - "message.channels", - "message.groups", - "message.im", - ], + "bot_events": bot_events, }, "interactivity": { "is_enabled": True, @@ -113,16 +140,21 @@ def slack_manifest_command(args) -> int: --description DESC Override the bot description --slashes-only Emit only the ``features.slash_commands`` array (for merging into an existing manifest manually) + --no-assistant Omit Slack AI Assistant mode (assistant_view feature, + assistant:write scope, assistant_thread_* events) so + DMs render as a flat chat where bare slash commands + work inline instead of the Assistant thread pane. """ name = getattr(args, "name", None) or "Hermes" description = getattr(args, "description", None) or "Your Hermes agent on Slack" + include_assistant = not getattr(args, "no_assistant", False) if getattr(args, "slashes_only", False): from hermes_cli.commands import slack_app_manifest manifest = slack_app_manifest()["features"]["slash_commands"] else: - manifest = _build_full_manifest(name, description) + manifest = _build_full_manifest(name, description, include_assistant=include_assistant) payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" diff --git a/hermes_cli/subcommands/slack.py b/hermes_cli/subcommands/slack.py index 28229c1fc6f..7debedf95a2 100644 --- a/hermes_cli/subcommands/slack.py +++ b/hermes_cli/subcommands/slack.py @@ -57,4 +57,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None: help="Emit only the features.slash_commands array (for merging " "into an existing manifest manually).", ) + slack_manifest.add_argument( + "--no-assistant", + action="store_true", + help="Omit Slack AI Assistant mode (assistant_view, assistant:write " + "scope, assistant_thread_* events). DMs then render as a flat chat " + "where bare slash commands (/help, /new) work inline instead of " + "Slack's Assistant thread pane.", + ) slack_parser.set_defaults(func=cmd_slack) diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index 8ccdb7119c0..2905859f003 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -1,6 +1,30 @@ """Tests for Slack CLI helpers.""" +import argparse + from hermes_cli.slack_cli import _build_full_manifest +from hermes_cli.subcommands.slack import build_slack_parser + + +def _parse_slack_args(argv): + """Build the real `hermes slack` parser and parse argv against it.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + build_slack_parser(subparsers, cmd_slack=lambda _args: 0) + return parser.parse_args(argv) + + +class TestSlackManifestArgparse: + """The `--no-assistant` flag wires through argparse to `no_assistant`.""" + + def test_no_assistant_flag_defaults_false(self): + args = _parse_slack_args(["slack", "manifest"]) + assert getattr(args, "no_assistant", False) is False + + def test_no_assistant_flag_sets_true(self): + args = _parse_slack_args(["slack", "manifest", "--no-assistant"]) + assert args.no_assistant is True + class TestSlackFullManifest: @@ -28,3 +52,35 @@ class TestSlackFullManifest: assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"] bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] assert "assistant_thread_started" in bot_events + + def test_no_assistant_omits_assistant_pieces(self): + manifest = _build_full_manifest( + "Hermes", "Your Hermes agent on Slack", include_assistant=False + ) + + # assistant_view feature is gone -> Slack renders a flat DM, not the + # Assistant thread pane (where bare slash commands don't dispatch). + assert "assistant_view" not in manifest["features"] + assert "assistant:write" not in manifest["oauth_config"]["scopes"]["bot"] + bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] + assert "assistant_thread_started" not in bot_events + assert "assistant_thread_context_changed" not in bot_events + + def test_no_assistant_preserves_core_surface(self): + """Dropping assistant mode must NOT strip the regular messaging surface.""" + manifest = _build_full_manifest( + "Hermes", "Your Hermes agent on Slack", include_assistant=False + ) + + # Flat DM still needs the Messages tab writable. + assert manifest["features"]["app_home"]["messages_tab_enabled"] is True + # Slash commands and Socket Mode are independent of assistant mode. + assert manifest["features"]["slash_commands"] + assert manifest["settings"]["socket_mode_enabled"] is True + # Channel + DM scopes/events survive so the bot still works everywhere. + bot_scopes = manifest["oauth_config"]["scopes"]["bot"] + for scope in ("commands", "channels:history", "groups:read", "im:history"): + assert scope in bot_scopes + bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] + for event in ("message.im", "message.channels", "message.groups", "app_mention"): + assert event in bot_events