{"repo":"NousResearch/hermes-agent","public_id":"34708","display_label":"Windows CLI wrapper should use .bat not shell script","summary":"A bug report and related fixes addressing that the CLI generates POSIX shell script wrappers on Windows, where .bat files are needed instead, including for profile aliases and custom names.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34796,"merged_at":"2026-05-29T19:32:47+00:00"},"resolution":{"state":"resolved","by":"pr-34796"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T19:32:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34708,"pr_number":34796,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T19:32:47+00:00"}],"members":[{"kind":"issue","number":34708,"title":"[Bug]: hermes profile create generates unusable #!/bin/sh wrapper on Windows — should create .bat file","url":null,"state":"closed"},{"kind":"pr","number":34714,"title":"fix(cli): create .bat wrapper on Windows instead of POSIX shell script","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6312dd8c3","number":0},{"kind":"pr","value":"#34796","number":34796}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34796,"title":"fix(cli): create .bat profile-alias wrappers on Windows (incl. custom names)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/profiles.py","tests/hermes_cli/test_profiles.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -10562,11 +10562,10 @@ def cmd_profile(args):\n             if collision:\n                 print(f\"Error: {collision}\")\n                 sys.exit(1)\n-            wrapper_path = create_wrapper_script(alias_name)\n+            wrapper_path = create_wrapper_script(\n+                alias_name, target=name if custom_name else None\n+            )\n             if wrapper_path:\n-                # If custom name, write the profile name into the wrapper\n-                if custom_name:\n-                    wrapper_path.write_text(f'#!/bin/sh\\nexec hermes -p {name} \"$@\"\\n')\n                 print(f\"✓ Alias created: {wrapper_path}\")\n                 if not _is_wrapper_dir_in_path():\n                     print(f\"⚠ {_get_wrapper_dir()} is not in your PATH.\")"]},{"file":"hermes_cli/profiles.py","hunks":["@@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:\n \n     # Check existing commands in PATH\n     wrapper_dir = _get_wrapper_dir()\n+    is_windows = sys.platform == \"win32\"\n     try:\n         result = subprocess.run(\n-            [\"which\", canon], capture_output=True, text=True, timeout=5,\n+            [\"where\" if is_windows else \"which\", canon],\n+            capture_output=True, text=True, timeout=5,\n         )\n         if result.returncode == 0:\n-            existing_path = result.stdout.strip()\n+            existing_path = result.stdout.strip().splitlines()[0]\n             # Allow overwriting our own wrappers\n-            if existing_path == str(wrapper_dir / canon):\n+            expected = wrapper_dir / (f\"{canon}.bat\" if is_windows else canon)\n+            if existing_path == str(expected):\n                 try:\n-                    content = (wrapper_dir / canon).read_text()\n+                    content = expected.read_text()\n                     if \"hermes -p\" in content:\n                         return None  # it's our wrapper, safe to overwrite\n                 except Exception:","@@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:\n     return wrapper_dir in os.environ.get(\"PATH\", \"\").split(os.pathsep)\n \n \n-def create_wrapper_script(name: str) -> Optional[Path]:\n+def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:\n     \"\"\"Create a shell wrapper script at ~/.local/bin/<name>.\n \n+    The wrapper file is named after ``name`` (the alias). The profile it\n+    activates is ``target`` if given, otherwise ``name`` — this lets a custom\n+    alias name point at a differently-named profile without a post-hoc rewrite.\n+\n+    On Windows, creates a ``.bat`` file instead of a POSIX shell script.\n     Returns the path to the created wrapper, or None if creation failed.\n     \"\"\"\n     canon = normalize_profile_name(name)\n+    profile = normalize_profile_name(target) if target else canon\n     wrapper_dir = _get_wrapper_dir()\n     try:\n         wrapper_dir.mkdir(parents=True, exist_ok=True)","@@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:\n         print(f\"⚠ Could not create {wrapper_dir}: {e}\")\n         return None\n \n-    wrapper_path = wrapper_dir / canon\n-    try:\n-        wrapper_path.write_text(f'#!/bin/sh\\nexec hermes -p {canon} \"$@\"\\n')\n-        wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)\n-        return wrapper_path\n-    except OSError as e:\n-        print(f\"⚠ Could not create wrapper at {wrapper_path}: {e}\")\n-        return None\n+    is_windows = sys.platform == \"win32\"\n+    if is_windows:\n+        wrapper_path = wrapper_dir / f\"{canon}.bat\"\n+        try:\n+            wrapper_path.write_text(f\"@echo off\\r\\nhermes -p {profile} %*\\r\\n\")\n+            return wrapper_path\n+        except OSError as e:\n+            print(f\"⚠ Could not create wrapper at {wrapper_path}: {e}\")\n+            return None\n+    else:\n+        wrapper_path = wrapper_dir / canon\n+        try:\n+            wrapper_path.write_text(f'#!/bin/sh\\nexec hermes -p {profile} \"$@\"\\n')\n+            wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)\n+            return wrapper_path\n+        except OSError as e:\n+            print(f\"⚠ Could not create wrapper at {wrapper_path}: {e}\")\n+            return None\n \n \n def remove_wrapper_script(name: str) -> bool:\n     \"\"\"Remove the wrapper script for a profile. Returns True if removed.\"\"\"\n-    wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)\n-    if wrapper_path.exists():\n-        try:\n-            # Verify it's our wrapper before removing\n-            content = wrapper_path.read_text()\n-            if \"hermes -p\" in content:\n-                wrapper_path.unlink()\n-                return True\n-        except Exception:\n-            pass\n+    wrapper_dir = _get_wrapper_dir()\n+    canon = normalize_profile_name(name)\n+    is_windows = sys.platform == \"win32\"\n+\n+    # Check both the extensionless path (POSIX) and .bat (Windows)\n+    candidates = [wrapper_dir / canon]\n+    if is_windows:\n+        candidates.insert(0, wrapper_dir / f\"{canon}.bat\")\n+\n+    for wrapper_path in candidates:\n+        if wrapper_path.exists():\n+            try:\n+                # Verify it's our wrapper before removing\n+                content = wrapper_path.read_text()\n+                if \"hermes -p\" in content:\n+                    wrapper_path.unlink()\n+                    return True\n+            except Exception:\n+                pass\n     return False\n \n "]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -600,6 +600,114 @@ def test_default_is_reserved(self, profile_env):\n         assert result is not None\n         assert \"reserved\" in result.lower()\n \n+    def test_uses_where_on_windows(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"win32\")\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=1, stdout=\"\")\n+            check_alias_collision(\"mybot\")\n+        call_args = mock_run.call_args[0][0]\n+        assert call_args[0] == \"where\"\n+\n+    def test_uses_which_on_posix(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"darwin\")\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=1, stdout=\"\")\n+            check_alias_collision(\"mybot\")\n+        call_args = mock_run.call_args[0][0]\n+        assert call_args[0] == \"which\"\n+\n+    def test_windows_checks_bat_extension(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"win32\")\n+        wrapper_dir = profile_env / \".local\" / \"bin\"\n+        wrapper_dir.mkdir(parents=True, exist_ok=True)\n+        bat_path = wrapper_dir / \"mybot.bat\"\n+        bat_path.write_text(\"@echo off\\r\\nhermes -p mybot %*\\r\\n\")\n+        with patch(\"subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0, stdout=str(bat_path),\n+            )\n+            result = check_alias_collision(\"mybot\")\n+        assert result is None  # our own wrapper, safe to overwrite\n+\n+\n+# ===================================================================\n+# TestWrapperScript\n+# ===================================================================\n+\n+class TestWrapperScript:\n+    \"\"\"Tests for create_wrapper_script() and remove_wrapper_script().\"\"\"\n+\n+    def test_creates_sh_on_posix(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"darwin\")\n+        from hermes_cli.profiles import create_wrapper_script\n+        wrapper = create_wrapper_script(\"mybot\")\n+        assert wrapper is not None\n+        assert wrapper.name == \"mybot\"\n+        content = wrapper.read_text()\n+        assert content.startswith(\"#!/bin/sh\")\n+        assert \"hermes -p mybot\" in content\n+\n+    def test_creates_bat_on_windows(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"win32\")\n+        from hermes_cli.profiles import create_wrapper_script\n+        wrapper = create_wrapper_script(\"mybot\")\n+        assert wrapper is not None\n+        assert wrapper.name == \"mybot.bat\"\n+        content = wrapper.read_text()\n+        assert \"@echo off\" in content\n+        assert \"hermes -p mybot\" in content\n+        assert \"%*\" in content\n+\n+    def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"win32\")\n+        from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script\n+        wrapper = create_wrapper_script(\"mybot\")\n+        assert wrapper is not None\n+        assert wrapper.exists()\n+        removed = remove_wrapper_script(\"mybot\")\n+        assert removed is True\n+        assert not wrapper.exists()\n+\n+    def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):\n+        monkeypatch.setattr(\"sys.platform\", \"darwin\")\n+        from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script\n+        wrapper = create_wrapper_script(\"mybot\")\n+        assert wrapper is not None\n+        assert wrapper.exists()\n+        removed = remove_wrapper_script(\"mybot\")\n+        assert removed is True\n+        assert not wrapper.exists()\n+\n+    def test_remove_returns_false_when_absent(self, profile_env):\n+        from hermes_cli.profiles import remove_wrapper_script\n+        assert remove_wrapper_script(\"nonexistent\") is False\n+\n+    def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):\n+        # Custom alias name pointing at a differently-named profile: the file\n+        # is named after the alias, the -p content references the profile.\n+        monkeypatch.setattr(\"sys.platform\", \"darwin\")\n+        from hermes_cli.profiles import create_wrapper_script\n+        wrapper = create_wrapper_script(\"rq\", target=\"redqueen\")\n+        assert wrapper is not None\n+        assert wrapper.name == \"rq\"\n+        content = wrapper.read_text()\n+        assert content.startswith(\"#!/bin/sh\")\n+        assert \"hermes -p redqueen\" in content\n+\n+    def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):\n+        # Regression: custom-name aliases must still produce an executable\n+        # .bat (not a clobbered #!/bin/sh) on Windows.\n+        monkeypatch.setattr(\"sys.platform\", \"win32\")\n+        from hermes_cli.profiles import create_wrapper_script\n+        wrapper = create_wrapper_script(\"rq\", target=\"redqueen\")\n+        assert wrapper is not None\n+        assert wrapper.name == \"rq.bat\"\n+        content = wrapper.read_text()\n+        assert \"@echo off\" in content\n+        assert \"hermes -p redqueen\" in content\n+        assert \"%*\" in content\n+        assert \"#!/bin/sh\" not in content\n+\n \n # ===================================================================\n # TestRenameProfile\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}