hermes-agent/hermes_cli/slack_cli.py
2001Y 0a5d8a16fc feat(slack): support long app descriptions in the manifest generator
Add --long-description / --long-description-file to `hermes slack
manifest` so the generated app manifest can carry Slack's
display_information.long_description (175–4,000 characters), with
validation of the length bounds, mutual-exclusion with --slashes-only,
and UTF-8 file input. Also propagate the manifest command's exit status
through cmd_slack so validation failures reach the shell.

Squash of the two commits from PR #65256 — one commit per contributor
on this salvage branch.

Salvaged from #65256
2026-07-23 12:01:24 -07:00

285 lines
10 KiB
Python

"""``hermes slack ...`` CLI subcommands.
Today only ``hermes slack manifest`` is implemented — it generates the
Slack app manifest JSON for registering every gateway command as a native
Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same
first-class slash UX Discord and Telegram already have.
Typical workflow::
$ hermes slack manifest > slack-manifest.json
# or:
$ hermes slack manifest --write
Then paste the printed JSON into the Slack app config (Features → App
Manifest → Edit) and click Save. Slack diffs the manifest and prompts
for reinstall when scopes/commands change.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
SLACK_LONG_DESCRIPTION_MIN_CHARACTERS = 175
SLACK_LONG_DESCRIPTION_MAX_CHARACTERS = 4000
def _build_full_manifest(
bot_name: str,
bot_description: str,
include_assistant: bool = True,
messaging_experience: str | None = None,
long_description: str | None = None,
) -> dict:
"""Build a full Slack manifest merging display info + our slash list.
The slash-command list is always generated from ``COMMAND_REGISTRY`` so
it stays in sync with the rest of Hermes. Other manifest sections
(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.
By default, this keeps Hermes on Slack's older Assistant messaging
experience (``assistant_view``) for backward compatibility. Pass
``messaging_experience="agent"`` (``--agent-view``) to emit Slack's Agent
messaging experience (``agent_view`` + ``app_home_opened``). Pass
``include_assistant=False`` or ``messaging_experience="none"``
(``--no-assistant``) to omit Slack AI messaging features and get a flat DM
surface where ``/help``, ``/new``, etc. work inline.
"""
from hermes_cli.commands import slack_app_manifest
if messaging_experience is None:
messaging_experience = "assistant" if include_assistant else "none"
messaging_experience = str(messaging_experience).strip().lower()
if messaging_experience not in {"assistant", "agent", "none"}:
raise ValueError(
"messaging_experience must be one of: assistant, agent, none"
)
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",
"mpim:history",
"mpim:read",
"reactions:read",
"users:read",
]
bot_events = [
"app_mention",
"message.channels",
"message.groups",
"message.im",
"message.mpim",
"reaction_added",
"reaction_removed",
]
if messaging_experience == "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",
]
)
elif messaging_experience == "agent":
features["agent_view"] = {
"agent_description": "Chat with Hermes in Slack Messages.",
}
bot_scopes.append("assistant:write")
# Slack includes current viewing context in Agent DM events only after
# this subscription is enabled; the adapter consumes that context to
# preserve the referred channel across the agent turn.
bot_events.extend(["app_context_changed", "app_home_opened"])
bot_scopes.sort()
bot_events.sort()
display_information = {
"name": bot_name[:35],
"description": (bot_description or "Your Hermes agent on Slack")[:140],
"background_color": "#1a1a2e",
}
if long_description is not None:
display_information["long_description"] = long_description
return {
"_metadata": {
"major_version": 1,
"minor_version": 1,
},
"display_information": display_information,
"features": features,
"oauth_config": {
"scopes": {
"bot": bot_scopes,
},
},
"settings": {
"event_subscriptions": {
"bot_events": bot_events,
},
"interactivity": {
"is_enabled": True,
},
"org_deploy_enabled": False,
"socket_mode_enabled": True,
"token_rotation_enabled": False,
},
}
def slack_manifest_command(args) -> int:
"""Print or write a Slack app manifest JSON.
Flags (all parsed in ``hermes_cli/main.py``):
--write [PATH] Write to file instead of stdout (default path:
``$HERMES_HOME/slack-manifest.json``)
--name NAME Override the bot display name (default: "Hermes")
--description DESC Override the bot description
--long-description TEXT Override the long app description (175-4,000 characters)
--long-description-file PATH Read the long app description from a UTF-8 file
--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.
--agent-view Use Slack's Agent messaging experience (agent_view,
app_home_opened + message.im) instead of the legacy
Assistant messaging experience.
"""
name = getattr(args, "name", None) or "Hermes"
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
long_description = getattr(args, "long_description", None)
long_description_file = getattr(args, "long_description_file", None)
if getattr(args, "slashes_only", False) and (
long_description is not None or long_description_file is not None
):
print(
"hermes slack manifest: long description options cannot be used "
"with --slashes-only",
file=sys.stderr,
)
return 2
if long_description_file is not None:
source_arg = str(long_description_file)
try:
source = Path(source_arg).expanduser()
with source.open("r", encoding="utf-8", newline="") as handle:
long_description = handle.read()
except (OSError, UnicodeError, RuntimeError) as exc:
print(
f"hermes slack manifest: cannot read long description from "
f"{source_arg}: {exc}",
file=sys.stderr,
)
return 2
if (
long_description is not None
and len(long_description) < SLACK_LONG_DESCRIPTION_MIN_CHARACTERS
):
print(
"hermes slack manifest: long description must be at least "
f"{SLACK_LONG_DESCRIPTION_MIN_CHARACTERS} characters "
f"(got {len(long_description)})",
file=sys.stderr,
)
return 2
if (
long_description is not None
and len(long_description) > SLACK_LONG_DESCRIPTION_MAX_CHARACTERS
):
print(
"hermes slack manifest: long description must be at most "
f"{SLACK_LONG_DESCRIPTION_MAX_CHARACTERS} characters "
f"(got {len(long_description)})",
file=sys.stderr,
)
return 2
if getattr(args, "agent_view", False):
messaging_experience = "agent"
elif getattr(args, "no_assistant", False):
messaging_experience = "none"
else:
messaging_experience = "assistant"
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,
messaging_experience=messaging_experience,
long_description=long_description,
)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
write_target = getattr(args, "write", None)
if write_target is not None:
if isinstance(write_target, bool) and write_target:
# --write with no value → default location
try:
from hermes_constants import get_hermes_home
target = Path(get_hermes_home()) / "slack-manifest.json"
except Exception:
target = Path(os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")) / "slack-manifest.json"
else:
target = Path(write_target).expanduser()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(payload, encoding="utf-8")
print(f"Slack manifest written to: {target}", file=sys.stderr)
print(
"\nNext steps:\n"
" 1. Open https://api.slack.com/apps and pick your Hermes app\n"
" (or create a new one: Create New App → From an app manifest).\n"
f" 2. Features → App Manifest → paste the contents of\n"
f" {target}\n"
" 3. Save; Slack will prompt to reinstall the app if scopes or\n"
" slash commands changed.\n"
" 4. Make sure Socket Mode is enabled and you have a bot token\n"
" (xoxb-...) and app token (xapp-...) configured via\n"
" `hermes setup`.\n",
file=sys.stderr,
)
else:
sys.stdout.write(payload)
return 0