From 8cda4458314266640b041bba5ac2ac465e1f9a66 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Fri, 25 Sep 2026 17:24:06 +0000 Subject: [PATCH] fix(copilot): detect copilot.exe on Windows instead of assuming copilot.cmd _copilot_executable() hardcoded the npm-shim name copilot.cmd on Windows, so workflow command-step dispatch failed for any Copilot CLI install that puts copilot.exe on PATH instead (standalone installer, winget, scoop). Probe PATH for copilot.exe, then copilot.cmd, before falling back to the historical default. Fixes #4755 Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous) --- .../integrations/copilot/__init__.py | 19 +++++++-- tests/integrations/test_extra_args.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 1a8285d7c4..9aa549c777 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", "copilot"): + 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: diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index 0ab68cb43a..44c488263c 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -639,6 +639,45 @@ 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") + monkeypatch.setattr( + shutil, "which", lambda name: r"C:\tools\copilot.exe" if name == "copilot.exe" else None + ) + 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_executable_env_var_copilot_dispatch_command(monkeypatch): """CopilotIntegration.dispatch_command honours the executable env var.""" import subprocess