# NousResearch/hermes-agent · Windows CLI wrapper should use .bat not shell script

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #34796. Verify it addresses your case.
- **Recommended:** read PR #34796 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-34796`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #34708: \[Bug\]: hermes profile create generates unusable #!/bin/sh wrapper on Windows — should create .bat file

## Prior & competing PRs

- **#34796: fix(cli): create .bat profile-alias wrappers on Windows (incl. custom names)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/main.py`, `hermes_cli/profiles.py`, `tests/hermes_cli/test_profiles.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -10562,11 +10562,10 @@ def cmd_profile(args):
                 if collision:
                     print(f"Error: {collision}")
                     sys.exit(1)
    -            wrapper_path = create_wrapper_script(alias_name)
    +            wrapper_path = create_wrapper_script(
    +                alias_name, target=name if custom_name else None
    +            )
                 if wrapper_path:
    -                # If custom name, write the profile name into the wrapper
    -                if custom_name:
    -                    wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
                     print(f"✓ Alias created: {wrapper_path}")
                     if not _is_wrapper_dir_in_path():
                         print(f"⚠ {_get_wrapper_dir()} is not in your PATH.")
    ```
  - `hermes_cli/profiles.py`

    ```diff
    @@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
     
         # Check existing commands in PATH
         wrapper_dir = _get_wrapper_dir()
    +    is_windows = sys.platform == "win32"
         try:
             result = subprocess.run(
    -            ["which", canon], capture_output=True, text=True, timeout=5,
    +            ["where" if is_windows else "which", canon],
    +            capture_output=True, text=True, timeout=5,
             )
             if result.returncode == 0:
    -            existing_path = result.stdout.strip()
    +            existing_path = result.stdout.strip().splitlines()[0]
                 # Allow overwriting our own wrappers
    -            if existing_path == str(wrapper_dir / canon):
    +            expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
    +            if existing_path == str(expected):
                     try:
    -                    content = (wrapper_dir / canon).read_text()
    +                    content = expected.read_text()
                         if "hermes -p" in content:
                             return None  # it's our wrapper, safe to overwrite
                     except Exception:
    ```

    ```diff
    @@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
         return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
     
     
    -def create_wrapper_script(name: str) -> Optional[Path]:
    +def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
         """Create a shell wrapper script at ~/.local/bin/<name>.
     
    +    The wrapper file is named after ``name`` (the alias). The profile it
    +    activates is ``target`` if given, otherwise ``name`` — this lets a custom
    +    alias name point at a differently-named profile without a post-hoc rewrite.
    +
    +    On Windows, creates a ``.bat`` file instead of a POSIX shell script.
         Returns the path to the created wrapper, or None if creation failed.
         """
         canon = normalize_profile_name(name)
    +    profile = normalize_profile_name(target) if target else canon
         wrapper_dir = _get_wrapper_dir()
         try:
             wrapper_dir.mkdir(parents=True, exist_ok=True)
    ```

    ```diff
    @@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:
             print(f"⚠ Could not create {wrapper_dir}: {e}")
             return None
     
    -    wrapper_path = wrapper_dir / canon
    -    try:
    -        wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
    -        wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
    -        return wrapper_path
    -    except OSError as e:
    -        print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
    -        return None
    +    is_windows = sys.platform == "win32"
    +    if is_windows:
    +        wrapper_path = wrapper_dir / f"{canon}.bat"
    +        try:
    +            wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
    +            return wrapper_path
    +        except OSError as e:
    +            print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
    +            return None
    +    else:
    +        wrapper_path = wrapper_dir / canon
    +        try:
    +            wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
    +            wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
    +            return wrapper_path
    +        except OSError as e:
    +            print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
    +            return None
     
     
     def remove_wrapper_script(name: str) -> bool:
         """Remove the wrapper script for a profile. Returns True if removed."""
    -    wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
    -    if wrapper_path.exists():
    -        try:
    -            # Verify it's our wrapper before removing
    -            content = wrapper_path.read_text()
    -            if "hermes -p" in content:
    -                wrapper_path.unlink()
    -                return True
    -        except Exception:
    -            pass
    +    wrapper_dir = _get_wrapper_dir()
    +    canon = normalize_profile_name(name)
    +    is_windows = sys.platform == "win32"
    +
    +    # Check both the extensionless path (POSIX) and .bat (Windows)
    +    candidates = [wrapper_dir / canon]
    +    if is_windows:
    +        candidates.insert(0, wrapper_dir / f"{canon}.bat")
    +
    +    for wrapper_path in candidates:
    +        if wrapper_path.exists():
    +            try:
    +                # Verify it's our wrapper before removing
    +                content = wrapper_path.read_text()
    +                if "hermes -p" in content:
    +                    wrapper_path.unlink()
    +                    return True
    +            except Exception:
    +                pass
         return False
     
     
    ```
  - `tests/hermes_cli/test_profiles.py`

    ```diff
    @@ -600,6 +600,114 @@ def test_default_is_reserved(self, profile_env):
             assert result is not None
             assert "reserved" in result.lower()
     
    +    def test_uses_where_on_windows(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "win32")
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(returncode=1, stdout="")
    +            check_alias_collision("mybot")
    +        call_args = mock_run.call_args[0][0]
    +        assert call_args[0] == "where"
    +
    +    def test_uses_which_on_posix(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "darwin")
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(returncode=1, stdout="")
    +            check_alias_collision("mybot")
    +        call_args = mock_run.call_args[0][0]
    +        assert call_args[0] == "which"
    +
    +    def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "win32")
    +        wrapper_dir = profile_env / ".local" / "bin"
    +        wrapper_dir.mkdir(parents=True, exist_ok=True)
    +        bat_path = wrapper_dir / "mybot.bat"
    +        bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
    +        with patch("subprocess.run") as mock_run:
    +            mock_run.return_value = MagicMock(
    +                returncode=0, stdout=str(bat_path),
    +            )
    +            result = check_alias_collision("mybot")
    +        assert result is None  # our own wrapper, safe to overwrite
    +
    +
    +# ===================================================================
    +# TestWrapperScript
    +# ===================================================================
    +
    +class TestWrapperScript:
    +    """Tests for create_wrapper_script() and remove_wrapper_script()."""
    +
    +    def test_creates_sh_on_posix(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "darwin")
    +        from hermes_cli.profiles import create_wrapper_script
    +        wrapper = create_wrapper_script("mybot")
    +        assert wrapper is not None
    +        assert wrapper.name == "mybot"
    +        content = wrapper.read_text()
    +        assert content.startswith("#!/bin/sh")
    +        assert "hermes -p mybot" in content
    +
    +    def test_creates_bat_on_windows(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "win32")
    +        from hermes_cli.profiles import create_wrapper_script
    +        wrapper = create_wrapper_script("mybot")
    +        assert wrapper is not None
    +        assert wrapper.name == "mybot.bat"
    +        content = wrapper.read_text()
    +        assert "@echo off" in content
    +        assert "hermes -p mybot" in content
    +        assert "%*" in content
    +
    +    def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "win32")
    +        from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
    +        wrapper = create_wrapper_script("mybot")
    +        assert wrapper is not None
    +        assert wrapper.exists()
    +        removed = remove_wrapper_script("mybot")
    +        assert removed is True
    +        assert not wrapper.exists()
    +
    +    def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):
    +        monkeypatch.setattr("sys.platform", "darwin")
    +        from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
    +        wrapper = create_wrapper_script("mybot")
    +        assert wrapper is not None
    +        assert wrapper.exists()
    +        removed = remove_wrapper_script("mybot")
    +        assert removed is True
    +        assert not wrapper.exists()
    +
    +    def test_remove_returns_false_when_absent(self, profile_env):
    +        from hermes_cli.profiles import remove_wrapper_script
    +        assert remove_wrapper_script("nonexistent") is False
    +
    +    def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):
    +        # Custom alias name pointing at a differently-named profile: the file
    +        # is named after the alias, the -p content references the profile.
    +        monkeypatch.setattr("sys.platform", "darwin")
    +        from hermes_cli.profiles import create_wrapper_script
    +        wrapper = create_wrapper_script("rq", target="redqueen")
    +        assert wrapper is not None
    +        assert wrapper.name == "rq"
    +        content = wrapper.read_text()
    +        assert content.startswith("#!/bin/sh")
    +        assert "hermes -p redqueen" in content
    +
    +    def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):
    +        # Regression: custom-name aliases must still produce an executable
    +        # .bat (not a clobbered #!/bin/sh) on Windows.
    +        monkeypatch.setattr("sys.platform", "win32")
    +        from hermes_cli.profiles import create_wrapper_script
    +        wrapper = create_wrapper_script("rq", target="redqueen")
    +        assert wrapper is not None
    +        assert wrapper.name == "rq.bat"
    +        content = wrapper.read_text()
    +        assert "@echo off" in content
    +        assert "hermes -p redqueen" in content
    +        assert "%*" in content
    +        assert "#!/bin/sh" not in content
    +
     
     # ===================================================================
     # TestRenameProfile
    ```
- **#34714: fix(cli): create .bat wrapper on Windows instead of POSIX shell script**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `6312dd8c3`, `#34796`

## Fix status

- Merged to main in PR #34796 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-29`.
