Skip to content

Commit 5b0a8ae

Browse files
committed
fix(workflows): preflight resolved integration executable
1 parent 659a41a commit 5b0a8ae

3 files changed

Lines changed: 87 additions & 4 deletions

File tree

src/specify_cli/workflows/steps/command/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ def _try_dispatch(
126126
if impl is None:
127127
return None
128128

129-
# Check if the integration supports CLI dispatch
130-
if impl.build_exec_args("test") is None:
129+
# Check if the integration supports CLI dispatch and use the executable
130+
# selected by build_exec_args() as the availability source of truth.
131+
exec_args = impl.build_exec_args("test")
132+
if exec_args is None:
131133
return None
132134

133135
# Check if the CLI tool is actually installed
134-
if not shutil.which(impl.key):
136+
if not shutil.which(exec_args[0]):
135137
return None
136138

137139
project_root = Path(context.project_root) if context.project_root else None

src/specify_cli/workflows/steps/prompt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def _try_dispatch(
118118
if exec_args is None:
119119
return None
120120

121-
if not shutil.which(impl.key):
121+
if not shutil.which(exec_args[0]):
122122
return None
123123

124124
import subprocess

tests/test_workflows.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,47 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch):
614614
# Claude is a SkillsIntegration so uses /speckit-specify
615615
assert "/speckit-specify login" in call_args[0][0][2]
616616

617+
def test_dispatch_uses_executable_override_for_preflight(self, tmp_path, monkeypatch):
618+
"""Command dispatch availability must follow build_exec_args() argv[0]."""
619+
from unittest.mock import MagicMock, patch
620+
from specify_cli.workflows.steps.command import CommandStep
621+
from specify_cli.workflows.base import StepContext, StepStatus
622+
623+
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
624+
seen_which: list[str] = []
625+
626+
def fake_which(name: str) -> str | None:
627+
seen_which.append(name)
628+
return name if name == "/opt/claude" else None
629+
630+
step = CommandStep()
631+
ctx = StepContext(
632+
inputs={"name": "login"},
633+
default_integration="claude",
634+
project_root=str(tmp_path),
635+
)
636+
config = {
637+
"id": "test",
638+
"command": "speckit.specify",
639+
"input": {"args": "{{ inputs.name }}"},
640+
}
641+
642+
mock_result = MagicMock()
643+
mock_result.returncode = 0
644+
mock_result.stdout = '{"result": "done"}'
645+
mock_result.stderr = ""
646+
647+
with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \
648+
patch("subprocess.run", return_value=mock_result) as mock_run:
649+
result = step.execute(config, ctx)
650+
651+
assert result.status == StepStatus.COMPLETED
652+
assert result.output["dispatched"] is True
653+
assert seen_which == ["/opt/claude"]
654+
call_args = mock_run.call_args
655+
assert call_args[0][0][0] == "/opt/claude"
656+
assert "/speckit-specify login" in call_args[0][0][2]
657+
617658
def test_dispatch_failure_returns_failed_status(self, tmp_path):
618659
"""When the CLI exits non-zero, the step should fail."""
619660
from unittest.mock import patch, MagicMock
@@ -734,6 +775,46 @@ def test_dispatch_with_mock_cli(self, tmp_path):
734775
assert result.output["dispatched"] is True
735776
assert result.output["exit_code"] == 0
736777

778+
def test_dispatch_uses_executable_override_for_preflight(self, tmp_path, monkeypatch):
779+
"""Prompt dispatch availability must follow build_exec_args() argv[0]."""
780+
from unittest.mock import MagicMock, patch
781+
from specify_cli.workflows.steps.prompt import PromptStep
782+
from specify_cli.workflows.base import StepContext, StepStatus
783+
784+
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
785+
seen_which: list[str] = []
786+
787+
def fake_which(name: str) -> str | None:
788+
seen_which.append(name)
789+
return name if name == "/opt/claude" else None
790+
791+
step = PromptStep()
792+
ctx = StepContext(
793+
default_integration="claude",
794+
project_root=str(tmp_path),
795+
)
796+
config = {
797+
"id": "ask",
798+
"type": "prompt",
799+
"prompt": "Explain this code",
800+
}
801+
802+
mock_result = MagicMock()
803+
mock_result.returncode = 0
804+
mock_result.stdout = "Here is the explanation"
805+
mock_result.stderr = ""
806+
807+
with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \
808+
patch("subprocess.run", return_value=mock_result) as mock_run:
809+
result = step.execute(config, ctx)
810+
811+
assert result.status == StepStatus.COMPLETED
812+
assert result.output["dispatched"] is True
813+
assert seen_which == ["/opt/claude"]
814+
call_args = mock_run.call_args
815+
assert call_args[0][0][0] == "/opt/claude"
816+
assert call_args[0][0][2] == "Explain this code"
817+
737818
def test_validate_missing_prompt(self):
738819
from specify_cli.workflows.steps.prompt import PromptStep
739820

0 commit comments

Comments
 (0)