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
This commit is contained in:
2001Y 2026-07-23 08:06:05 -07:00 committed by Teknium
parent 805c22c836
commit 0a5d8a16fc
6 changed files with 334 additions and 7 deletions

View file

@ -4489,7 +4489,10 @@ def cmd_slack(args):
if sub == "manifest":
from hermes_cli.slack_cli import slack_manifest_command
return slack_manifest_command(args)
status = slack_manifest_command(args)
if status:
raise SystemExit(status)
return status
print(f"Unknown slack subcommand: {sub}", file=sys.stderr)
return 1

View file

@ -23,11 +23,16 @@ 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.
@ -124,16 +129,20 @@ def _build_full_manifest(
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": {
"name": bot_name[:35],
"description": (bot_description or "Your Hermes agent on Slack")[:140],
"background_color": "#1a1a2e",
},
"display_information": display_information,
"features": features,
"oauth_config": {
"scopes": {
@ -162,6 +171,8 @@ def slack_manifest_command(args) -> int:
``$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,
@ -174,6 +185,52 @@ def slack_manifest_command(args) -> int:
"""
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):
@ -190,6 +247,7 @@ def slack_manifest_command(args) -> int:
name,
description,
messaging_experience=messaging_experience,
long_description=long_description,
)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"

View file

@ -51,6 +51,22 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
default=None,
help="Bot description shown in Slack's app directory.",
)
slack_long_description = slack_manifest.add_mutually_exclusive_group()
slack_long_description.add_argument(
"--long-description",
default=None,
metavar="TEXT",
help="Set Slack's long app description (175-4,000 characters).",
)
slack_long_description.add_argument(
"--long-description-file",
default=None,
metavar="PATH",
help=(
"Read Slack's long app description from a UTF-8 text file "
"(175-4,000 characters)."
),
)
slack_manifest.add_argument(
"--slashes-only",
action="store_true",

View file

@ -1,8 +1,14 @@
"""Tests for Slack CLI helpers."""
import argparse
import json
import subprocess
import sys
from pathlib import Path
from hermes_cli.slack_cli import _build_full_manifest
import pytest
from hermes_cli.slack_cli import _build_full_manifest, slack_manifest_command
from hermes_cli.subcommands.slack import build_slack_parser
@ -14,6 +20,71 @@ def _parse_slack_args(argv):
return parser.parse_args(argv)
def _run_console_entrypoint(*argv: str) -> subprocess.CompletedProcess[str]:
"""Run the packaged console-script contract in a fresh interpreter."""
return subprocess.run(
[
sys.executable,
"-c",
"from hermes_cli.main import main; raise SystemExit(main())",
*argv,
],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
text=True,
timeout=30,
check=False,
)
def test_slack_dispatcher_propagates_manifest_failure(monkeypatch):
from hermes_cli import main as main_module
from hermes_cli import slack_cli
monkeypatch.setattr(slack_cli, "slack_manifest_command", lambda _args: 2)
with pytest.raises(SystemExit) as exc_info:
main_module.cmd_slack(argparse.Namespace(slack_command="manifest"))
assert exc_info.value.code == 2
class TestSlackManifestConsoleExitStatus:
"""The packaged CLI must expose manifest validation failures to shells."""
def test_too_short_long_description_exits_two(self):
result = _run_console_entrypoint(
"slack", "manifest", "--long-description", "x" * 174
)
assert result.returncode == 2
assert result.stdout == ""
assert "at least 175 characters" in result.stderr
def test_missing_long_description_file_exits_two(self, tmp_path):
missing = tmp_path / "missing.md"
result = _run_console_entrypoint(
"slack", "manifest", "--long-description-file", str(missing)
)
assert result.returncode == 2
assert result.stdout == ""
assert "cannot read long description" in result.stderr
def test_slashes_only_conflict_exits_two(self):
result = _run_console_entrypoint(
"slack",
"manifest",
"--slashes-only",
"--long-description",
"x" * 175,
)
assert result.returncode == 2
assert result.stdout == ""
assert "cannot be used with --slashes-only" in result.stderr
class TestSlackManifestArgparse:
"""Slack manifest messaging-experience flags wire through argparse."""
@ -33,10 +104,173 @@ class TestSlackManifestArgparse:
args = _parse_slack_args(["slack", "manifest", "--agent-view"])
assert args.agent_view is True
def test_long_description_file_preserves_newlines(self, tmp_path, capsys):
content = ("x" * 175) + "\r\n" + ("y" * 175) + "\r"
source = tmp_path / "AGENTS.md"
source.write_bytes(content.encode("utf-8"))
args = _parse_slack_args(
["slack", "manifest", "--long-description-file", str(source)]
)
assert slack_manifest_command(args) == 0
manifest = json.loads(capsys.readouterr().out)
assert manifest["display_information"]["long_description"] == content
def test_long_description_accepts_inline_text(self, capsys):
content = "x" * 175
args = _parse_slack_args(
["slack", "manifest", "--long-description", content]
)
assert slack_manifest_command(args) == 0
manifest = json.loads(capsys.readouterr().out)
assert manifest["display_information"]["long_description"] == content
def test_long_description_rejects_fewer_than_175_characters(self, capsys):
args = _parse_slack_args(
["slack", "manifest", "--long-description", "x" * 174]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "at least 175 characters" in captured.err
def test_long_description_rejects_more_than_4000_characters(self, capsys):
args = _parse_slack_args(
["slack", "manifest", "--long-description", "x" * 4001]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "4000 characters" in captured.err
def test_long_description_options_are_mutually_exclusive(self):
with pytest.raises(SystemExit) as exc_info:
_parse_slack_args(
[
"slack",
"manifest",
"--long-description",
"inline",
"--long-description-file",
"AGENTS.md",
]
)
assert exc_info.value.code == 2
@pytest.mark.parametrize(
("option", "value"),
[
("--long-description", "x" * 175),
("--long-description-file", "missing.md"),
],
)
def test_long_description_options_reject_slashes_only(
self, option, value, capsys
):
args = _parse_slack_args(
["slack", "manifest", "--slashes-only", option, value]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "cannot be used with --slashes-only" in captured.err
def test_long_description_file_reports_read_errors(self, tmp_path, capsys):
missing = tmp_path / "missing.md"
args = _parse_slack_args(
["slack", "manifest", "--long-description-file", str(missing)]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "cannot read long description" in captured.err
@pytest.mark.parametrize(
("length", "expected_status"),
[(174, 2), (175, 0), (4000, 0), (4001, 2)],
)
def test_long_description_file_enforces_slack_length_boundaries(
self, tmp_path, capsys, length, expected_status
):
content = "x" * length
source = tmp_path / "AGENTS.md"
source.write_text(content, encoding="utf-8")
args = _parse_slack_args(
["slack", "manifest", "--long-description-file", str(source)]
)
assert slack_manifest_command(args) == expected_status
captured = capsys.readouterr()
if expected_status == 0:
manifest = json.loads(captured.out)
assert manifest["display_information"]["long_description"] == content
assert captured.err == ""
else:
assert captured.out == ""
assert "long description must be" in captured.err
def test_long_description_file_reports_invalid_utf8(self, tmp_path, capsys):
source = tmp_path / "AGENTS.md"
source.write_bytes(b"\xff" * 175)
args = _parse_slack_args(
["slack", "manifest", "--long-description-file", str(source)]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "cannot read long description" in captured.err
def test_long_description_file_reports_tilde_expansion_errors(
self, monkeypatch, capsys
):
source = "~hermes-user-that-does-not-exist-20260716/AGENTS.md"
def fail_expanduser(_path):
raise RuntimeError("home directory unavailable")
monkeypatch.setattr(Path, "expanduser", fail_expanduser)
args = _parse_slack_args(
["slack", "manifest", "--long-description-file", source]
)
assert slack_manifest_command(args) == 2
captured = capsys.readouterr()
assert captured.out == ""
assert "cannot read long description" in captured.err
assert source in captured.err
class TestSlackFullManifest:
"""Generated full Slack app manifest used by `hermes slack manifest`."""
def test_long_description_is_included_without_truncation(self):
long_description = "# Agent policy\n\n" + ("x" * 3984)
manifest = _build_full_manifest(
"Hermes",
"Your Hermes agent on Slack",
long_description=long_description,
)
assert manifest["display_information"]["long_description"] == long_description
assert len(long_description) == 4000
def test_app_home_messages_are_writable(self):
manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack")

View file

@ -343,6 +343,7 @@ Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairi
```bash
hermes slack manifest # print manifest to stdout
hermes slack manifest --write # write to ~/.hermes/slack-manifest.json
hermes slack manifest --long-description-file AGENTS.md --write
hermes slack manifest --slashes-only # just the features.slash_commands array
```
@ -359,6 +360,8 @@ reinstall if scopes or slash commands changed.
| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.json`. |
| `--name NAME` | `Hermes` | Bot display name in Slack. |
| `--description DESC` | default blurb | Bot description shown in the Slack app directory. |
| `--long-description TEXT` | unset | Set `display_information.long_description` inline (1754,000 characters). Incompatible with `--slashes-only`. |
| `--long-description-file PATH` | unset | Read the long description from a UTF-8 text file, preserving its contents exactly. Mutually exclusive with `--long-description` and incompatible with `--slashes-only`. |
| `--slashes-only` | off | Emit only `features.slash_commands` for merging into a manually-maintained manifest. |
Run `hermes slack manifest --write` again after `hermes update` to pick

View file

@ -43,6 +43,19 @@ Mode — all at once.
This writes `~/.hermes/slack-manifest.json` and prints paste-in
instructions. Existing apps that still use Slack's legacy Assistant view
can omit `--agent-view` until they are ready to migrate.
To populate Slack's long app description from an existing UTF-8 text or
Markdown file, add `--long-description-file`:
```bash
hermes slack manifest --agent-view \
--long-description-file AGENTS.md --write
```
The file contents are preserved exactly within Slack's 1754,000-character
range. Use `--long-description "..."` for inline text instead; the inline
and file options are mutually exclusive and cannot be combined with
`--slashes-only`.
2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →
**Create New App** → **From an app manifest**
3. Pick your workspace, paste the JSON contents, review, click **Next**