NousResearch/hermes-agent

Windows CLI wrapper should use .bat not shell script

Cluster 34708. Data as of

Nothing open on GitHub. Fix merged in #34796 on 29 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #34796. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #34796. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #34796 fix(cli): create .bat profile-alias wrappers on Windows (incl. custom names)

    merged to main

    files: hermes_cli/main.py, hermes_cli/profiles.py, tests/hermes_cli/test_profiles.py

    Show the diff
    • hermes_cli/main.py
      @@ -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
      @@ -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:
      @@ -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)
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #34714 fix(cli): create .bat wrapper on Windows instead of POSIX shell script · merged via another change → 6312dd8, #34796
Closed issues · 1
  • Closed#34708[Bug]: hermes profile create generates unusable #!/bin/sh wrapper on Windows — should create .bat file
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#34796
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
29 May 2026

For your agent

The same facts, typed.