{"repo":"NousResearch/hermes-agent","public_id":"744","display_label":"Shell command passthrough and quick command bypass features","summary":"These issues and PRs concern adding a `!` prefix for direct shell command execution from chat input, and ensuring exec-type quick commands bypass the agent loop or gateway draining guard rather than being routed as text.","subsystem":"gateway","counts":{"members":10,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":4,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":746},"resolution":{"state":"partially_resolved","by":"pr-746"},"freshness":{"state":"stale","last_verified_at":"2026-03-11T07:25:56+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13688888888888887},"fix_edges":[{"issue_number":744,"pr_number":746,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":744,"title":"Feature Request: Native User-Defined Quick Commands That Bypass the Agent Loop","url":null,"state":"closed"},{"kind":"issue","number":7545,"title":"[Feature]: Bang (!) prefix to run shell commands inline from chat input","url":null,"state":"closed"},{"kind":"issue","number":25783,"title":"bug: exec quick commands routed to agent as text when agent is mid-turn","url":null,"state":"open"},{"kind":"issue","number":28663,"title":"Exec-type quick commands are blocked when gateway is draining","url":null,"state":"open"},{"kind":"issue","number":33766,"title":"feat: `!command` prefix for direct shell execution (like Claude Code)","url":null,"state":"closed"},{"kind":"issue","number":53341,"title":"feat(cli): add `!` prefix for direct shell command passthrough","url":null,"state":"closed"},{"kind":"pr","number":746,"title":"feat(cli,gateway): add user-defined quick commands that bypass agent loop","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","tests/test_quick_commands.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -2400,9 +2400,32 @@ def process_command(self, command: str) -> bool:\n         elif cmd_lower == \"/reload-mcp\":\n             self._reload_mcp()\n         else:\n-            # Check for skill slash commands (/gif-search, /axolotl, etc.)\n+            # Check for user-defined quick commands (bypass agent loop, no LLM call)\n             base_cmd = cmd_lower.split()[0]\n-            if base_cmd in _skill_commands:\n+            quick_commands = self.config.get(\"quick_commands\", {})\n+            if base_cmd.lstrip(\"/\") in quick_commands:\n+                qcmd = quick_commands[base_cmd.lstrip(\"/\")]\n+                if qcmd.get(\"type\") == \"exec\":\n+                    import subprocess\n+                    exec_cmd = qcmd.get(\"command\", \"\")\n+                    if exec_cmd:\n+                        try:\n+                            result = subprocess.run(\n+                                exec_cmd, shell=True, capture_output=True,\n+                                text=True, timeout=30\n+                            )\n+                            output = result.stdout.strip() or result.stderr.strip()\n+                            self.console.print(output if output else \"[dim]Command returned no output[/]\")\n+                        except subprocess.TimeoutExpired:\n+                            self.console.print(\"[bold red]Quick command timed out (30s)[/]\")\n+                        except Exception as e:\n+                            self.console.print(f\"[bold red]Quick command error: {e}[/]\")\n+                    else:\n+                        self.console.print(f\"[bold red]Quick command '{base_cmd}' has no command defined[/]\")\n+                else:\n+                    self.console.print(f\"[bold red]Quick command '{base_cmd}' has unsupported type (only 'exec' is supported)[/]\")\n+            # Check for skill slash commands (/gif-search, /axolotl, etc.)\n+            elif base_cmd in _skill_commands:\n                 user_instruction = cmd_original[len(base_cmd):].strip()\n                 msg = build_skill_invocation_message(base_cmd, user_instruction)\n                 if msg:"]},{"file":"gateway/run.py","hunks":["@@ -771,6 +771,33 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         if command == \"resume\":\n             return await self._handle_resume_command(event)\n         \n+        # User-defined quick commands (bypass agent loop, no LLM call)\n+        if command:\n+            quick_commands = self.config.get(\"quick_commands\", {})\n+            if command in quick_commands:\n+                qcmd = quick_commands[command]\n+                if qcmd.get(\"type\") == \"exec\":\n+                    import asyncio\n+                    exec_cmd = qcmd.get(\"command\", \"\")\n+                    if exec_cmd:\n+                        try:\n+                            proc = await asyncio.create_subprocess_shell(\n+                                exec_cmd,\n+                                stdout=asyncio.subprocess.PIPE,\n+                                stderr=asyncio.subprocess.PIPE,\n+                            )\n+                            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)\n+                            output = (stdout or stderr).decode().strip()\n+                            return output if output else \"Command returned no output.\"\n+                        except asyncio.TimeoutError:\n+                            return \"Quick command timed out (30s).\"\n+                        except Exception as e:\n+                            return f\"Quick command error: {e}\"\n+                    else:\n+                        return f\"Quick command '/{command}' has no command defined.\"\n+                else:\n+                    return f\"Quick command '/{command}' has unsupported type (only 'exec' is supported).\"\n+\n         # Skill slash commands: /skill-name loads the skill and sends to agent\n         if command:\n             try:"]},{"file":"hermes_cli/config.py","hunks":["@@ -147,6 +147,8 @@ def ensure_hermes_home():\n \n     # Permanently allowed dangerous command patterns (added via \"always\" approval)\n     \"command_allowlist\": [],\n+    # User-defined quick commands that bypass the agent loop (type: exec only)\n+    \"quick_commands\": {},\n \n     # Config schema version - bump this when adding new required fields\n     \"_config_version\": 5,"]},{"file":"tests/test_quick_commands.py","hunks":["@@ -0,0 +1,137 @@\n+\"\"\"Tests for user-defined quick commands that bypass the agent loop.\"\"\"\n+import subprocess\n+from unittest.mock import MagicMock, patch, AsyncMock\n+import pytest\n+\n+\n+# ── CLI tests ──────────────────────────────────────────────────────────────\n+\n+class TestCLIQuickCommands:\n+    \"\"\"Test quick command dispatch in HermesCLI.process_command.\"\"\"\n+\n+    def _make_cli(self, quick_commands):\n+        from cli import HermesCLI\n+        cli = HermesCLI.__new__(HermesCLI)\n+        cli.config = {\"quick_commands\": quick_commands}\n+        cli.console = MagicMock()\n+        cli.agent = None\n+        cli.conversation_history = []\n+        return cli\n+\n+    def test_exec_command_runs_and_prints_output(self):\n+        cli = self._make_cli({\"dn\": {\"type\": \"exec\", \"command\": \"echo daily-note\"}})\n+        result = cli.process_command(\"/dn\")\n+        assert result is True\n+        cli.console.print.assert_called_once_with(\"daily-note\")\n+\n+    def test_exec_command_stderr_shown_on_no_stdout(self):\n+        cli = self._make_cli({\"err\": {\"type\": \"exec\", \"command\": \"echo error >&2\"}})\n+        result = cli.process_command(\"/err\")\n+        assert result is True\n+        # stderr fallback — should print something\n+        cli.console.print.assert_called_once()\n+\n+    def test_exec_command_no_output_shows_fallback(self):\n+        cli = self._make_cli({\"empty\": {\"type\": \"exec\", \"command\": \"true\"}})\n+        cli.process_command(\"/empty\")\n+        cli.console.print.assert_called_once()\n+        args = cli.console.print.call_args[0][0]\n+        assert \"no output\" in args.lower()\n+\n+    def test_unsupported_type_shows_error(self):\n+        cli = self._make_cli({\"bad\": {\"type\": \"prompt\", \"command\": \"echo hi\"}})\n+        cli.process_command(\"/bad\")\n+        cli.console.print.assert_called_once()\n+        args = cli.console.print.call_args[0][0]\n+        assert \"unsupported type\" in args.lower()\n+\n+    def test_missing_command_field_shows_error(self):\n+        cli = self._make_cli({\"oops\": {\"type\": \"exec\"}})\n+        cli.process_command(\"/oops\")\n+        cli.console.print.assert_called_once()\n+        args = cli.console.print.call_args[0][0]\n+        assert \"no command defined\" in args.lower()\n+\n+    def test_quick_command_takes_priority_over_skill_commands(self):\n+        \"\"\"Quick commands must be checked before skill slash commands.\"\"\"\n+        cli = self._make_cli({\"mygif\": {\"type\": \"exec\", \"command\": \"echo overridden\"}})\n+        with patch(\"cli._skill_commands\", {\"/mygif\": {\"name\": \"gif-search\"}}):\n+            cli.process_command(\"/mygif\")\n+        cli.console.print.assert_called_once_with(\"overridden\")\n+\n+    def test_unknown_command_still_shows_error(self):\n+        cli = self._make_cli({})\n+        cli.process_command(\"/nonexistent\")\n+        cli.console.print.assert_called()\n+        args = cli.console.print.call_args_list[0][0][0]\n+        assert \"unknown command\" in args.lower()\n+\n+    def test_timeout_shows_error(self):\n+        cli = self._make_cli({\"slow\": {\"type\": \"exec\", \"command\": \"sleep 100\"}})\n+        with patch(\"subprocess.run\", side_effect=subprocess.TimeoutExpired(\"sleep\", 30)):\n+            cli.process_command(\"/slow\")\n+        cli.console.print.assert_called_once()\n+        args = cli.console.print.call_args[0][0]\n+        assert \"timed out\" in args.lower()\n+\n+\n+# ── Gateway tests ──────────────────────────────────────────────────────────\n+\n+class TestGatewayQuickCommands:\n+    \"\"\"Test quick command dispatch in GatewayRunner._handle_message.\"\"\"\n+\n+    def _make_event(self, command, args=\"\"):\n+        event = MagicMock()\n+        event.get_command.return_value = command\n+        event.get_command_args.return_value = args\n+        event.text = f\"/{command} {args}\".strip()\n+        event.source = MagicMock()\n+        event.source.user_id = \"test_user\"\n+        event.source.user_name = \"Test User\"\n+        event.source.platform.value = \"telegram\"\n+        event.source.chat_type = \"dm\"\n+        event.source.chat_id = \"123\"\n+        return event\n+\n+    @pytest.mark.asyncio\n+    async def test_exec_command_returns_output(self):\n+        from gateway.run import GatewayRunner\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = {\"quick_commands\": {\"limits\": {\"type\": \"exec\", \"command\": \"echo ok\"}}}\n+        runner._running_agents = {}\n+        runner._pending_messages = {}\n+        runner._is_user_authorized = MagicMock(return_value=True)\n+\n+        event = self._make_event(\"limits\")\n+        result = await runner._handle_message(event)\n+        assert result == \"ok\"\n+\n+    @pytest.mark.asyncio\n+    async def test_unsupported_type_returns_error(self):\n+        from gateway.run import GatewayRunner\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = {\"quick_commands\": {\"bad\": {\"type\": \"prompt\", \"command\": \"echo hi\"}}}\n+        runner._running_agents = {}\n+        runner._pending_messages = {}\n+        runner._is_user_authorized = MagicMock(return_value=True)\n+\n+        event = self._make_event(\"bad\")\n+        result = await runner._handle_message(event)\n+        assert result is not None\n+        assert \"unsupported type\" in result.lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_timeout_returns_error(self):\n+        from gateway.run import GatewayRunner\n+        import asyncio\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = {\"quick_commands\": {\"slow\": {\"type\": \"exec\", \"command\": \"sleep 100\"}}}\n+        runner._running_agents = {}\n+        runner._pending_messages = {}\n+        runner._is_user_authorized = MagicMock(return_value=True)\n+\n+        event = self._make_event(\"slow\")\n+        with patch(\"asyncio.wait_for\", side_effect=asyncio.TimeoutError):\n+            result = await runner._handle_message(event)\n+        assert result is not None\n+        assert \"timed out\" in result.lower()\n"]}]},{"kind":"pr","number":7728,"title":"feat(cli): add ! prefix to run shell commands inline","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9704ed86c1","number":0},{"kind":"pr","value":"#7545","number":7545}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":25804,"title":"fix(gateway): run exec quick commands inline when agent is mid-turn (#25783)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":28734,"title":"fix(gateway): allow exec-type quick commands to bypass draining guard","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}