diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 1a8285d7c4..ace60fe217 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -49,11 +49,22 @@ def _copilot_executable() -> str: """Return the executable name for Copilot CLI on this platform. - On Windows, subprocess invocation is reliable with `copilot.cmd`. + On Windows, the Copilot CLI may be installed as `copilot.exe` (e.g. a + standalone installer, winget, scoop) or as a `copilot.cmd` npm shim. + Probe `PATH` for whichever is actually present instead of assuming the + npm-style shim. """ - if os.name == "nt": - return "copilot.cmd" - return "copilot" + if os.name != "nt": + return "copilot" + + for candidate in ("copilot.exe", "copilot.cmd"): + if shutil.which(candidate): + return candidate + + # Nothing found on PATH — keep the historical default so the + # resulting "command not found" error still references the + # previously expected name. + return "copilot.cmd" def _allow_all() -> bool: @@ -293,9 +304,10 @@ def _resolve_executable(self) -> str: """Return the Copilot CLI executable, respecting the env-var override. Checks ``SPECKIT_INTEGRATION_COPILOT_EXECUTABLE`` first. Falls - back to the platform-specific default from ``_copilot_executable()`` - (``copilot.cmd`` on Windows, ``copilot`` elsewhere) so that - existing behaviour is preserved when the env var is unset. + back to the platform-specific default from ``_copilot_executable()``: + on Windows this probes ``PATH`` for ``copilot.exe`` then + ``copilot.cmd``, only falling back to ``copilot.cmd`` when neither is + found; elsewhere it is always ``copilot``. """ env_name = "SPECKIT_INTEGRATION_COPILOT_EXECUTABLE" override = os.environ.get(env_name, "").strip() diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index 0ab68cb43a..6babdee392 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -639,6 +639,59 @@ def test_executable_env_var_copilot_unset_uses_platform_default(monkeypatch): assert args[0] == _copilot_executable() +def test_copilot_executable_windows_prefers_exe_on_path(monkeypatch): + """On Windows, `_copilot_executable()` must detect a `copilot.exe` + install rather than assuming the npm `copilot.cmd` shim (#4755).""" + import shutil + + from specify_cli.integrations.copilot import _copilot_executable + + monkeypatch.setattr(os, "name", "nt") + paths = {"copilot.exe": r"C:\tools\copilot.exe", "copilot.cmd": r"C:\tools\copilot.cmd"} + monkeypatch.setattr(shutil, "which", lambda name: paths.get(name)) + assert _copilot_executable() == "copilot.exe" + + +def test_copilot_executable_windows_falls_back_to_cmd_shim(monkeypatch): + """A Windows install exposing only `copilot.cmd` (npm shim) still works.""" + import shutil + + from specify_cli.integrations.copilot import _copilot_executable + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + shutil, "which", lambda name: r"C:\tools\copilot.cmd" if name == "copilot.cmd" else None + ) + assert _copilot_executable() == "copilot.cmd" + + +def test_copilot_executable_windows_nothing_on_path_keeps_historical_default(monkeypatch): + """Nothing found on PATH keeps the historical `copilot.cmd` default so + the resulting error still names the previously expected executable.""" + import shutil + + from specify_cli.integrations.copilot import _copilot_executable + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(shutil, "which", lambda name: None) + assert _copilot_executable() == "copilot.cmd" + + +def test_copilot_executable_windows_ignores_unlaunchable_bare_name(monkeypatch): + """A bare `copilot` match (e.g. a `.bat`/`.com` resolved via `PATHEXT`) + must not be returned: `CreateProcess` doesn't consult `PATHEXT`, so a + bare name detected this way can't actually be launched.""" + import shutil + + from specify_cli.integrations.copilot import _copilot_executable + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + shutil, "which", lambda name: r"C:\tools\copilot.bat" if name == "copilot" else None + ) + assert _copilot_executable() == "copilot.cmd" + + def test_executable_env_var_copilot_dispatch_command(monkeypatch): """CopilotIntegration.dispatch_command honours the executable env var.""" import subprocess diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d8abcc0f55..bd1332edbe 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1024,11 +1024,10 @@ def test_codex_exec_args(self): def test_copilot_exec_args(self, monkeypatch): monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False) monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) - from specify_cli.integrations.copilot import CopilotIntegration + from specify_cli.integrations.copilot import CopilotIntegration, _copilot_executable impl = CopilotIntegration() args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") - expected_exec = "copilot.cmd" if os.name == "nt" else "copilot" - assert args[0] == expected_exec + assert args[0] == _copilot_executable() assert "-p" in args assert "--yolo" in args assert "--model" in args