From 96ce133928df62affa22114f0ea3ba928c1ef932 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 28 Jul 2026 19:07:09 +0500 Subject: [PATCH] fix(workflows): dispatch prompt steps via the resolved executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PromptStep._try_dispatch runs `subprocess.run(exec_args, ...)` with an UNRESOLVED argv[0] -- a bare name like `claude`. On Windows subprocess.run calls CreateProcess, which does not consult PATHEXT, so an agent CLI installed as a `.cmd`/`.bat` shim (the usual npm layout) raises FileNotFoundError [WinError 2]. That OSError is swallowed by the method's `except OSError: return None`, and execute() then reports "CLI not found or not installed" -- even though the step's own preflight `shutil.which(...)` two lines earlier just found it. The sibling path does not have this bug: IntegrationBase.dispatch_command (used by the `command` step) resolves argv[0] through shutil.which first, added in 8e5643d for exactly this reason. Same machine, same integration, CLI present as a .cmd shim: type: prompt -> failed "integration 'claude' CLI not found or not installed." type: command -> completed Primitive confirmation: bare `subprocess.run(["fakeagent"])` raises [WinError 2] while `subprocess.run([shutil.which("fakeagent")])` runs fine. Reuse the path the preflight already resolved (`fallback_cli_path`) instead of calling which() again, so the shim is executed. On POSIX it is the same executable, so behaviour is unchanged there. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../workflows/steps/prompt/__init__.py | 11 ++++++ tests/test_workflows.py | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py index ed92e1eea2..b17b670b53 100644 --- a/src/specify_cli/workflows/steps/prompt/__init__.py +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -167,6 +167,17 @@ def _try_dispatch( if not exec_args: return None + # Windows: ``subprocess.run`` calls ``CreateProcess``, which does not + # consult ``PATHEXT``, so a bare command name like ``claude`` installed + # as ``claude.cmd`` (the usual npm shim layout) fails with + # ``WinError 2``. That OSError is swallowed below and reported as "CLI + # not found or not installed" -- even though the preflight above just + # found it. Reuse the already-resolved path so the shim is executed, + # mirroring ``IntegrationBase.dispatch_command``, which the ``command`` + # step already goes through. On POSIX this is the same executable. + if fallback_cli_path: + exec_args = [fallback_cli_path, *exec_args[1:]] + import subprocess project_root = ( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 61c625a396..24966b4f47 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1486,6 +1486,41 @@ def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path): assert result.output["dispatched"] is True assert result.output["exit_code"] == 0 + def test_try_dispatch_executes_the_resolved_executable(self, tmp_path): + """argv[0] must be the shutil.which-resolved path, not the bare name. + + On Windows subprocess.run calls CreateProcess, which ignores PATHEXT, so + a bare `claude` installed as `claude.cmd` (the usual npm shim) raises + WinError 2. That OSError is swallowed and reported as "CLI not found or + not installed" even though the preflight which() just found it, while + the `command` step -- which goes through + IntegrationBase.dispatch_command -- resolves argv[0] and works. + """ + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext(default_integration="claude", project_root=str(tmp_path)) + config = {"id": "test", "type": "prompt", "prompt": "hello"} + + resolved = r"C:\tools\claude.CMD" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch( + "specify_cli.workflows.steps.prompt.shutil.which", + lambda name: resolved, + ), patch("subprocess.run", return_value=mock_result) as run: + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + argv = run.call_args.args[0] + assert argv[0] == resolved, argv + def test_dispatch_with_mock_cli(self, tmp_path): from unittest.mock import patch, MagicMock from specify_cli.workflows.steps.prompt import PromptStep