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
Open PRs0
Fixes on record
-
#34796 fix(cli): create .bat profile-alias wrappers on Windows (incl. custom names)
files:
hermes_cli/main.py,hermes_cli/profiles.py,tests/hermes_cli/test_profiles.pyShow 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.00means 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 LabAt a glance
- Merged PRs
- #34796
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 29 May 2026