diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index f455556e36..520e417523 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -9,6 +9,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any @@ -91,6 +92,35 @@ class ForgeIntegration(MarkdownIntegration): } invoke_separator = "-" + 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: + """Build CLI arguments for non-interactive ``forge`` execution. + + ``MarkdownIntegration``'s default appends ``--model`` and + ``--output-format``, neither of which exists in the Forge CLI (see + issue #4666) — a dispatched step exits 2 at argument parsing + whenever either flag ends up appended (a configured ``model``, or + ``output_json=True``). Forge only accepts ``-p/--prompt``; ``model`` + is deliberately dropped rather than remapped, since Forge selects it + out of band via ``forge config set model`` and its ``--agent`` flag + selects an agent ID, not a model. + """ + self.validate_runtime_config(integration_args, integration_options) + args = [self._resolve_executable()] + # Forge's global flags parse before -p, so extra args go first + # (matches opencode / goose / codex / cursor-agent ordering). + self._apply_extra_args_env_var(args) + args.extend(["-p", prompt]) + return args + def build_command_invocation(self, command_name: str, args: str = "") -> str: """Forge installs hyphenated slash-commands (``/speckit-``), so the dispatch invocation must match. The inherited MarkdownIntegration default diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 0559e9be98..96b7fd0e5a 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -282,6 +282,51 @@ def test_name_field_uses_hyphenated_format(self, tmp_path): ) +class TestForgeExecArgs: + """Forge only accepts `-p/--prompt`; `--model` and `--output-format` do + not exist in the Forge CLI and abort dispatch with exit code 2 (#4666).""" + + def test_build_exec_args_uses_prompt_flag_only(self): + forge = get_integration("forge") + + args = forge.build_exec_args("/speckit-plan add OAuth", output_json=False) + + assert args == ["forge", "-p", "/speckit-plan add OAuth"] + + def test_build_exec_args_omits_output_format_flag(self): + """`--output-format` is not a Forge flag; requesting JSON output must + not append it.""" + forge = get_integration("forge") + + args = forge.build_exec_args("hello", output_json=True) + + assert args == ["forge", "-p", "hello"] + assert "--output-format" not in args + + def test_build_exec_args_omits_model_flag(self): + """Forge has no model-selection flag; `model` is set out of band via + `forge config set model`, so it must not be forwarded onto `--model` + or `--agent` (the latter selects an agent ID, not a model).""" + forge = get_integration("forge") + + args = forge.build_exec_args("hello", model="gpt-4o", output_json=True) + + assert args == ["forge", "-p", "hello"] + assert "--model" not in args + assert "--agent" not in args + assert "gpt-4o" not in args + + def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch): + """Forge's global flags parse before -p, so operator-injected extra + args go first (matches opencode / goose / codex / cursor-agent).""" + monkeypatch.setenv("SPECKIT_INTEGRATION_FORGE_EXTRA_ARGS", "--verbose") + forge = get_integration("forge") + + args = forge.build_exec_args("check the build", output_json=False) + + assert args == ["forge", "--verbose", "-p", "check the build"] + + class TestForgeCommandRegistrar: """Test CommandRegistrar's Forge-specific name formatting."""