diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bae5c929775c..fcca52b8851e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index fa5a971ff353..51e4059be5d7 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -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" diff --git a/hermes_cli/subcommands/slack.py b/hermes_cli/subcommands/slack.py index 0cce8929ea8f..5eb424986e14 100644 --- a/hermes_cli/subcommands/slack.py +++ b/hermes_cli/subcommands/slack.py @@ -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", diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index 2ca462cb6bb6..82f37708eebe 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -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") diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 41d5d1219546..228e0c3f4ea6 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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 diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 16a4e1373ee1..683d1a823c7d 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -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**