diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 7ae12d9468..c6c94180ee 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -42,7 +42,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Qoder CLI](https://qoder.com/cli) | `qodercli` | | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | | [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | Workflow `command` and `prompt` steps can't run through SHAI: its CLI accepts prompt text (as arguments, on stdin, or through `shai agent`), but hands it to its auto-fix agent and never loads `.shai/commands`, so those steps fail | | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | | [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | | [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 8be9596bf1..941f02dbfd 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -1,5 +1,11 @@ """SHAI CLI integration.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + from ..base import MarkdownIntegration @@ -19,3 +25,26 @@ class ShaiIntegration(MarkdownIntegration): "extension": ".md", } multi_install_safe = True + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + integration_args: Sequence[str] | None = None, + integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, + ) -> list[str] | None: + # SHAI takes headless prompt text from positional arguments, stdin or + # `shai agent `, but every route goes to its auto-fix + # agent (`handle_fix` in shai-cli/src/main.rs of ovh/shai), and + # `.shai/commands` is never loaded, so no route runs an installed + # Spec Kit command. Positional arguments collect `-p` and `--model` + # too (`trailing_var_arg`/`allow_hyphen_values`), so the inherited + # `shai -p ` handed that literal text to the auto-fix agent, + # which exits 0, and workflow steps reported success without running + # the command (#2416). Opt out of CLI dispatch and let those steps + # fail instead. + self.validate_runtime_config(integration_args, integration_options) + return None diff --git a/src/specify_cli/workflows/step/command/__init__.py b/src/specify_cli/workflows/step/command/__init__.py index 439007112f..eb719d9cd4 100644 --- a/src/specify_cli/workflows/step/command/__init__.py +++ b/src/specify_cli/workflows/step/command/__init__.py @@ -189,8 +189,12 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: output=output, error=( f"Cannot dispatch command {command!r}: " - f"integration {integration!r} CLI not found or not installed. " - f"Install the CLI tool or check 'specify integration list'." + f"integration {integration!r} CLI not found or not installed, " + f"or the integration does not support CLI dispatch. " + f"If the CLI is missing, install it or check " + f"'specify integration list'; if the integration does not " + f"support CLI dispatch, set the step's 'integration' to one " + f"that does." ), ) diff --git a/src/specify_cli/workflows/step/prompt/__init__.py b/src/specify_cli/workflows/step/prompt/__init__.py index 9c2e683bf4..4cfb16fb0e 100644 --- a/src/specify_cli/workflows/step/prompt/__init__.py +++ b/src/specify_cli/workflows/step/prompt/__init__.py @@ -137,7 +137,11 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: error=( f"Cannot dispatch prompt: " f"integration {integration!r} " - f"CLI not found or not installed." + f"CLI not found or not installed, " + f"or the integration does not support CLI dispatch. " + f"If the CLI is missing, install it; if the integration " + f"does not support CLI dispatch, set the step's " + f"'integration' to one that does." ), ) diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py index fc2b60c3f2..24f786e967 100644 --- a/tests/integrations/test_integration_shai.py +++ b/tests/integrations/test_integration_shai.py @@ -1,5 +1,12 @@ """Tests for ShaiIntegration.""" +from unittest.mock import MagicMock, patch + +from specify_cli.integrations import get_integration +from specify_cli.workflows.base import StepContext, StepStatus +from specify_cli.workflows.step.command import CommandStep +from specify_cli.workflows.step.prompt import PromptStep + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -8,3 +15,45 @@ class TestShaiIntegration(MarkdownIntegrationTests): FOLDER = ".shai/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".shai/commands" + + +class TestShaiCliDispatch: + """SHAI's CLI can't run an installed Spec Kit command (#2416). + + SHAI's argument, stdin and `shai agent ` routes all pass + the text to its auto-fix agent, which exits 0, and `.shai/commands` is + never loaded, so dispatching `shai -p ` marked workflow steps + completed without running the command. + """ + + def test_build_exec_args_opts_out(self): + integration = get_integration("shai") + assert integration.build_exec_args("/speckit.plan", model="m") is None + + def _run(self, step, config, tmp_path): + ctx = StepContext(default_integration="shai", project_root=str(tmp_path)) + exited_ok = MagicMock(returncode=0, stdout="", stderr="") + with patch("shutil.which", return_value="/usr/local/bin/shai"), \ + patch("subprocess.run", return_value=exited_ok) as run: + result = step.execute(config, ctx) + return result, run + + def test_command_step_fails_instead_of_running_shai(self, tmp_path): + result, run = self._run( + CommandStep(), {"id": "plan", "command": "speckit.plan"}, tmp_path + ) + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is False + assert "does not support CLI dispatch" in result.error + assert "set the step's 'integration'" in result.error + run.assert_not_called() + + def test_prompt_step_fails_instead_of_running_shai(self, tmp_path): + result, run = self._run( + PromptStep(), {"id": "ask", "prompt": "Summarize the spec"}, tmp_path + ) + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is False + assert "does not support CLI dispatch" in result.error + assert "set the step's 'integration'" in result.error + run.assert_not_called()