mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
805c22c836
commit
0a5d8a16fc
6 changed files with 334 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (175–4,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
|
||||
|
|
|
|||
|
|
@ -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 175–4,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**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue