From 5bf5cb0ad3e17c0870fa13a4b3ca8eaf02bfb282 Mon Sep 17 00:00:00 2001 From: hmohammadi Date: Tue, 22 Sep 2026 10:40:15 +0100 Subject: [PATCH] fix(integrations): dispatch Forge via prompt flag only Fixes #4666. `ForgeIntegration` never overrode `build_exec_args()`, so it inherited `MarkdownIntegration`'s generic `-p --model --output-format json`. Forge accepts `-p/--prompt`, but `--model` and `--output-format` do not exist in its CLI, so every workflow `command:`/`prompt:` step targeting Forge aborted at argument parsing with `error: unexpected argument '--model' found` (exit 2) before the agent ever ran. Unlike the Amp case, both inherited extras are invalid, so both dispatch paths broke: `stream=False` appends `--output-format json`, while `stream=True` still appends `--model` whenever a model is configured. The only surviving combination was `stream=True` with no model, which reduces to plain `forge -p `. `model` is deliberately dropped rather than remapped: Forge exposes no model-selection flag. Model choice is a persisted setting (`forge config set model`), and `--agent` takes an agent ID, not a model identifier, so forwarding the caller's model onto it would silently select the wrong thing. `output_json` is dropped for the same reason: Forge's machine-readable `--porcelain` exists only on certain subcommands, not on the top-level prompt invocation. Extra args from `SPECKIT_INTEGRATION_FORGE_EXTRA_ARGS` are applied before `-p`, matching the opencode / goose / codex ordering; Forge parses its global flags ahead of the prompt flag. Same fix shape as the one-off overrides for Amp (#4581), opencode (#2409) and goose (#3781). Part of the audit in #2416. Co-Authored-By: Claude Opus 5 --- .../integrations/forge/__init__.py | 31 +++++++++ tests/integrations/test_integration_forge.py | 68 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index f455556e36..b57a8657d4 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 @@ -103,6 +104,36 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: invocation = f"{invocation} {args}" return invocation + 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: + self.validate_runtime_config(integration_args, integration_options) + args = [self._resolve_executable()] + # Operator-injected extra args go before -p: Forge parses its global + # flags ahead of the prompt flag, matching the opencode/goose/codex + # ordering. + self._apply_extra_args_env_var(args) + + args.extend(["-p", prompt]) + + # `model` is deliberately dropped: Forge has no model-selection flag. + # Model choice is a persisted setting (`forge config set model`), and + # `--agent` takes an agent ID rather than a model identifier, so + # forwarding the caller's model onto it would silently select the + # wrong thing. + # + # `output_json` is likewise dropped: Forge has no `--output-format`. + # Its machine-readable `--porcelain` exists only on certain + # subcommands, not on the top-level prompt invocation. + return args + def setup( self, project_root: Path, diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 0559e9be98..830c506c4d 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -281,6 +281,74 @@ def test_name_field_uses_hyphenated_format(self, tmp_path): f"{cmd_file.name} name field should start with 'speckit-': {name_value}" ) + def test_build_exec_args_uses_prompt_flag(self): + """Forge dispatches through ``-p``, with no extra flags appended. + + Forge accepts ``-p/--prompt``, but the inherited ``--model`` and + ``--output-format`` do not exist in its CLI; sending them aborts the + run at argument parsing with ``unexpected argument`` (#4666). + """ + integration = get_integration("forge") + + args = integration.build_exec_args( + "/speckit-specify build a login page", + model="gpt-4o", + output_json=True, + ) + + assert args == [ + "forge", + "-p", + "/speckit-specify build a login page", + ] + + def test_build_exec_args_omits_output_format(self): + """Forge has no ``--output-format``; requesting JSON must not add one.""" + integration = get_integration("forge") + + args = integration.build_exec_args("/speckit-plan add OAuth", output_json=True) + + assert args == ["forge", "-p", "/speckit-plan add OAuth"] + assert "--output-format" not in args + assert "json" not in args + + def test_build_exec_args_omits_model_flag(self): + """Forge exposes no model-selection flag, so ``model`` is not forwarded. + + Model choice is a persisted setting (``forge config set model``). + ``--agent`` takes an agent ID, not a model identifier, so remapping the + caller's model onto it would select the wrong thing. + """ + integration = get_integration("forge") + + args = integration.build_exec_args( + "explain this repository", + model="gpt-4o", + output_json=False, + ) + + assert args == ["forge", "-p", "explain this repository"] + assert "--model" not in args + assert "gpt-4o" not in args + + def test_build_exec_args_applies_extra_args_before_prompt(self, monkeypatch): + """Operator-injected flags precede ``-p`` so they stay global. + + Forge parses its global flags ahead of ``-p`` (``forge --verbose -p x`` + is accepted), matching the opencode / goose / codex ordering. + """ + monkeypatch.setenv("SPECKIT_INTEGRATION_FORGE_EXTRA_ARGS", "--verbose") + integration = get_integration("forge") + + args = integration.build_exec_args("check the build", output_json=True) + + assert args == [ + "forge", + "--verbose", + "-p", + "check the build", + ] + class TestForgeCommandRegistrar: """Test CommandRegistrar's Forge-specific name formatting."""